From f42132ed5b291f86dc43169d5266e9aa82e0ffaf Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Mon, 18 May 2026 18:40:25 +0200 Subject: [PATCH] chore: add flyer import module and configuration - Added FlyerImportModule to AppModule imports - Created new flyer-import module directory - Added .kilo/ configuration directory --- .kilo/plans/1779121909294-silent-star.md | 185 ++++++++++++++ backend/src/app.module.ts | 4 +- .../flyer-import/dto/flyer-import.response.ts | 26 ++ .../flyer-import/flyer-import.controller.ts | 59 +++++ .../src/flyer-import/flyer-import.module.ts | 11 + .../src/flyer-import/flyer-import.service.ts | 237 ++++++++++++++++++ 6 files changed, 521 insertions(+), 1 deletion(-) create mode 100644 .kilo/plans/1779121909294-silent-star.md create mode 100644 backend/src/flyer-import/dto/flyer-import.response.ts create mode 100644 backend/src/flyer-import/flyer-import.controller.ts create mode 100644 backend/src/flyer-import/flyer-import.module.ts create mode 100644 backend/src/flyer-import/flyer-import.service.ts diff --git a/.kilo/plans/1779121909294-silent-star.md b/.kilo/plans/1779121909294-silent-star.md new file mode 100644 index 00000000..11c67e84 --- /dev/null +++ b/.kilo/plans/1779121909294-silent-star.md @@ -0,0 +1,185 @@ +# Analys av `plan importer willys.md.txt` i projektkontext + +## Kontext och utgångsläge + +Den analyserade planen finns i `microservice-importer/plan importer willys.md.txt` och beskriver ett förslag för: +- premium-gating av AI +- PDF-import av Willys-underlag +- produktmatchning mot inventory +- receptgenerering med AI (Mistral) eller mallar + +Jämfört med faktisk arkitektur i era tre repos: +- `recipe-app`: äger användare, premium/AI-flaggor, inventory, recept, auth, adminflöden och databas (Prisma + MariaDB). +- `microservice-importer`: stateless import/parsing utan databas; ansvarar för quick-import, markdown-parse och receipt-parse. +- `recipe-gitea-runner`: CI-exekvering med labels `backend-node24` och `flutter-3-41`. + +## Sammanfattande bedömning + +Planen är ambitiös men **inte direkt kompatibel** med nuvarande systemdesign. Den blandar ansvar mellan tjänster, använder kodmönster som avviker från era etablerade stackval och återinför funktioner ni redan implementerat i annan form. + +Hög nivå: +- Bra: tydlig stegstruktur, fallback-idé (AI -> templates), fokus på robust importflöde. +- Problem: arkitekturdrift (DB i importer), felaktig domänmodell för era nuvarande User/Product-fält, Express-exempel i NestJS-miljö, svag validering/säkerhet i flera snippets. + +## Styrkor att återanvända + +- Tydlig domänuppdelning i delsteg: extrahering -> parsing -> matchning -> generering. +- Fallback-first-princip vid AI-fel (ligger i linje med era befintliga principer). +- Intention att normalisera och strukturera råtext innan beslut i senare led. +- Fokus på svenska enheter/uttryck som passar era kvittoflöden. + +## Kritiska gap mot befintlig arkitektur + +1. **Fel placerat ansvar (största gapet)** +- Planen föreslår Prisma/DB i importer-flödet (`prisma.user`, `encryptedData.create`, premiumfält m.m.). +- Er importer är dokumenterat stateless och DB-lös. +- Rekommendation: all user/premium/inventory/recipe persistence ska ligga i `recipe-app` backend, inte i `microservice-importer`. + +2. **Premium-modell avviker från faktisk datamodell** +- Planen använder `is_premium` och `premium_expiry_date`. +- I `recipe-app` används `isPremium` + `aiEngineEnabled` redan, med admin-styrning och JWT-scope. +- Rekommendation: återanvänd befintliga fält/guards; undvik parallel premiummodell. + +3. **Framework mismatch (Express vs NestJS)** +- Planen innehåller Express-routerexempel medan repos är NestJS-moduler/controllers/services. +- Rekommendation: all ny implementation bör följa NestJS module/service/controller + DTO + class-validator. + +4. **Datamodell-krockar** +- Planen antar tabeller/fält som inte finns i nuvarande schema (`encryptedData`, snake_case-kolumner osv). +- Rekommendation: mappa mot faktiska modeller (`User`, `Product`, `InventoryItem`, `Recipe*`) eller skapa tydlig migrationplan med namngiven adapter. + +5. **Överlapp med befintlig funktionalitet** +- Ni har redan importerad kvittoparsning med regelmotor + AI-fallback och premium/ai-scope. +- Rekommendation: undvik ”nytt parallellt flöde”; bygg som utökning av befintliga receipt/import pipelines. + +## Tekniska förbättringar av själva planen + +### A. Arkitekturförbättringar (måste prioriteras) + +- **Inför tydligt kontrakt mellan tjänsterna** + - `microservice-importer`: parse/normalize only (ingen userstate). + - `recipe-app`: auth, premium-gating, produktmatchning, persistence. +- **Skapa explicit API-kontrakt för kampanjblad/Willys** + - ny endpoint i importer, exempel: `POST /api/flyer/parse`. + - svar med strikt schema: produkter, erbjudandeflaggor, normaliserade mått/enheter, confidence. +- **Beslut i recipe-app** + - besluta AI/template på basis av `isPremium && aiEngineEnabled`. + - lagra endast i recipe-app DB. + +### B. Datakontrakt och validering + +- Ersätt `any` med typed DTO/interfaces i båda repos. +- Lägg till strikt validering (zod eller class-validator) för: + - parsed flyer rows + - matched product payload + - generated recipe payload +- Definiera versionerat kontrakt (`v1`) så importer och app kan deployas oberoende. + +### C. Parser-kvalitet och robusthet (Willys-specifikt) + +- Nuvarande regex-idé är för skör för verkliga PDF-varianter. +- Förbättra med pipeline: + 1) textblock-normalisering + 2) radklassificering (kategori, produkt, prisrad, metadata) + 3) enhetsnormalisering (`förp`, `st`, `kg`) + 4) probabilistisk matchscore per fält +- Lägg in rule priority + fallback AI bara för osäkra rader (som ni redan gör i receipt flow). + +### D. Matchning mot inventory + +- Planens fuzzy-match på 0.6 riskerar falska positiva. +- Förslag: + - kombinera alias > exact-normalized > token-similarity > levenshtein. + - category-guardrails (finns redan i receipt-flöde, återanvänd). + - trösklar per kategori (mejeri/kött behöver striktare gränser än exotiska varor). + - returnera `matchedVia`, `confidence`, `reasonCodes` för UI-debugg och lärande. + +### E. AI-generering + +- Planen gör fri JSON-parsning från modelltext; hög risk för parse-fel. +- Förslag: + - använd strikt output schema + reparationssteg vid JSON-avvikelse. + - lägg budget/timeouts/retries per request. + - prompta på begränsad produktmängd (top-N relevanta) för lägre kostnad/latens. + - logga token-användning och feltyper för cost observability. + +### F. Säkerhet + +- Behåll premium-kontroll i backend (ej klient). +- Lägg rate limiting även på nya flyer-endpoints. +- Undvik filsystemberoende (`multer dest + fs.unlinkSync`) om möjligt; använd bufferbaserad pipeline. +- Lägg explicit content-type + storleksgräns + filsignature-validering. + +### G. Drift och CI (recipe-gitea-runner) + +- Lägg nya testjobb med labels ni redan har: + - `backend-node24`: contract tests mellan app/importer. + - `backend-node24`: parser regression suite med fixtures från riktiga Willys-underlag. +- Lägg minimikrav i CI: + - typecheck + - unit tests parser + matcher + - contract tests importer <-> recipe-app + - build +- Publicera artifact med parser-rapport (precision/recall på fixture-set) för varje PR. + +## Konkreta optimeringar per repo + +### 1) `microservice-importer` + +- Lägg till separat modul `flyer-parsing` istället för att återanvända receipt rakt av. +- Returnera endast normaliserad och validerad struktur, aldrig användarspecifika beslut. +- Återanvänd befintlig robusthet: + - fallback parsing + - timeout/retry-mönster + - global exception shape. +- Bygg fixture-driven tester för Willys-format (varianter med OCR-brus, multipack, kampanjtext). + +### 2) `recipe-app` + +- Implementera orkestreringstjänst för flyerimport: + - anropa importer + - matcha mot user-scopade produkter + - premium-gata AI-recept (isPremium + aiEngineEnabled) + - spara recept via befintliga modeller +- Exponera adminfeature toggle för ”flyer-recipe-generation” (separation från övrig AI om ni vill kontrollera rollout). +- Lägg telemetri per steg: parse time, match confidence distribution, AI fallback rate. + +### 3) `recipe-gitea-runner` + +- Säkra att workflow i `recipe-app` inkluderar integrationstest mot importer (mockad eller ephemeral service). +- Lägg nattlig regressionkörning för parser-fixtures för att fånga drift i regex/regler. +- Behåll labels som idag; komplettera med tydligare jobbseparation i CI (quick PR vs full push). + +## Prioriterad implementeringsplan (reviderad) + +1. **Målbild/ansvar (P0)** +- Fastställ och dokumentera kontrakt: importer parser-only, app stateful orchestration. + +2. **Kontrakt + DTO (P0)** +- Definiera `FlyerParseResponse v1` och valideringsregler. + +3. **Importer-modul (P1)** +- Implementera Willys/flyer parser i `microservice-importer` med tester + fixtures. + +4. **Recipe-app orkestrering (P1)** +- Ny service i backend som mappar parse-resultat till matchning + recipe generation. + +5. **Premium-gating harmonisering (P1)** +- Använd enbart `isPremium` + `aiEngineEnabled`; ta bort/undvik expiry-logik om den inte behövs produktmässigt. + +6. **Observability + säkerhet (P1)** +- Metrics, structured logs, rate limits, upload guards. + +7. **CI-utbyggnad (P2)** +- Contract tests + parser regression suite i Gitea workflows. + +## Risker om planen implementeras oförändrad + +- Arkitekturspret: dubbla sanningskällor för premium och recipes. +- Ökad driftkomplexitet: importer blir stateful och svårare att skala/deploya. +- Regressionsrisk: ny kod duplicerar befintlig receipt/importlogik. +- Säkerhets- och datakvalitetsrisk: svag typing/validering + osäker JSON-parsning från AI-svar. + +## Rekommenderad riktning (kort) + +Använd planen som **idékatalog**, inte som direkt implementation. Behåll er nuvarande ansvarsfördelning mellan repos, bygg Willys-stödet som en ny parserdomän i `microservice-importer`, och låt `recipe-app` fortsätta vara enda platsen för användarlogik, premiumbeslut och datalagring. Detta ger lägst risk och bäst kompatibilitet med er nuvarande kodbas, driftmodell och CI-upplägg. diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e275c85a..55d10cd6 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -18,6 +18,7 @@ import { CategoriesModule } from './categories/categories.module'; import { AiModule } from './ai/ai.module'; import { RealtimeModule } from './realtime/realtime.module'; import { HelpTextsModule } from './help-texts/help-texts.module'; +import { FlyerImportModule } from './flyer-import/flyer-import.module'; import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { RolesGuard } from './auth/roles.guard'; @@ -48,6 +49,7 @@ import { RolesGuard } from './auth/roles.guard'; AiModule, RealtimeModule, HelpTextsModule, + FlyerImportModule, ], providers: [ { @@ -64,4 +66,4 @@ import { RolesGuard } from './auth/roles.guard'; }, ], }) -export class AppModule {} \ No newline at end of file +export class AppModule {} diff --git a/backend/src/flyer-import/dto/flyer-import.response.ts b/backend/src/flyer-import/dto/flyer-import.response.ts new file mode 100644 index 00000000..808d3fc5 --- /dev/null +++ b/backend/src/flyer-import/dto/flyer-import.response.ts @@ -0,0 +1,26 @@ +export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none'; + +export type FlyerImportItem = { + rawName: string; + normalizedName: string; + category: string | null; + price: number | null; + priceUnit: string | null; + comparisonPrice: number | null; + comparisonUnit: string | null; + offerText: string | null; + parseConfidence: number; + parseReasons: string[]; + matchedProductId: number | null; + matchedProductName: string | null; + matchedVia: FlyerImportMatchVia; + matchConfidence: number; + matchReasons: string[]; +}; + +export type FlyerImportResponse = { + retailer: 'willys'; + parserVersion: 'v1'; + items: FlyerImportItem[]; + warnings: string[]; +}; diff --git a/backend/src/flyer-import/flyer-import.controller.ts b/backend/src/flyer-import/flyer-import.controller.ts new file mode 100644 index 00000000..e272e6ad --- /dev/null +++ b/backend/src/flyer-import/flyer-import.controller.ts @@ -0,0 +1,59 @@ +import { + BadRequestException, + Controller, + HttpCode, + Post, + Request, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import { FlyerImportResponse } from './dto/flyer-import.response'; +import { FlyerImportService } from './flyer-import.service'; + +const ALLOWED_MIMES = [ + 'application/pdf', + 'application/octet-stream', + 'text/plain', +]; + +@Controller('flyer-import') +export class FlyerImportController { + constructor(private readonly flyerImportService: FlyerImportService) {} + + @Post('parse') + @HttpCode(200) + @Throttle({ default: { ttl: 60_000, limit: 10 } }) + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: 15 * 1024 * 1024 }, + }), + ) + async parseFlyer( + @UploadedFile() file?: Express.Multer.File, + @Request() req?: any, + ): Promise { + if (!file?.buffer) { + throw new BadRequestException('Ingen fil skickades med.'); + } + if (!ALLOWED_MIMES.includes(file.mimetype)) { + throw new BadRequestException('Otillåten filtyp. Använd PDF eller textfil.'); + } + + const userId = + typeof req?.user?.id === 'number' + ? req.user.id + : typeof req?.user?.userId === 'number' + ? req.user.userId + : undefined; + + if (!userId) { + throw new BadRequestException('Kunde inte identifiera användaren.'); + } + + return this.flyerImportService.parseAndMatch(file, userId); + } +} diff --git a/backend/src/flyer-import/flyer-import.module.ts b/backend/src/flyer-import/flyer-import.module.ts new file mode 100644 index 00000000..e209cb66 --- /dev/null +++ b/backend/src/flyer-import/flyer-import.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { FlyerImportController } from './flyer-import.controller'; +import { FlyerImportService } from './flyer-import.service'; + +@Module({ + imports: [PrismaModule], + controllers: [FlyerImportController], + providers: [FlyerImportService], +}) +export class FlyerImportModule {} diff --git a/backend/src/flyer-import/flyer-import.service.ts b/backend/src/flyer-import/flyer-import.service.ts new file mode 100644 index 00000000..7bcb2083 --- /dev/null +++ b/backend/src/flyer-import/flyer-import.service.ts @@ -0,0 +1,237 @@ +import { + BadRequestException, + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { normalizeName } from '../common/utils/normalize-name'; +import { + FlyerImportItem, + FlyerImportMatchVia, + FlyerImportResponse, +} from './dto/flyer-import.response'; + +const IMPORTER_SERVICE_URL = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001'; + +type FlyerParseItem = { + rawName: string; + normalizedName: string; + category: string | null; + price: number | null; + priceUnit: string | null; + comparisonPrice: number | null; + comparisonUnit: string | null; + offerText: string | null; + confidence: number; + reasonCodes: string[]; +}; + +type FlyerParseResponse = { + retailer: 'willys'; + parserVersion: 'v1'; + items: FlyerParseItem[]; + warnings: string[]; +}; + +type ProductLite = { + id: number; + name: string; + canonicalName: string | null; +}; + +@Injectable() +export class FlyerImportService { + private readonly logger = new Logger(FlyerImportService.name); + + constructor(private readonly prisma: PrismaService) {} + + async parseAndMatch(file: Express.Multer.File, userId: number): Promise { + const parsed = await this.parseViaImporter(file); + + const [products, aliases] = await Promise.all([ + this.prisma.product.findMany({ + where: { ownerId: userId, isActive: true }, + select: { id: true, name: true, canonicalName: true }, + }), + this.prisma.receiptAlias.findMany({ + where: { + OR: [{ ownerId: userId, isGlobal: false }, { isGlobal: true }], + }, + select: { receiptName: true, productId: true }, + }), + ]); + + const aliasToProduct = new Map(); + for (const alias of aliases) { + const normalized = normalizeName(alias.receiptName); + if (!normalized) continue; + if (!aliasToProduct.has(normalized)) { + aliasToProduct.set(normalized, alias.productId); + } + } + + const productById = new Map(); + for (const product of products) { + productById.set(product.id, product); + } + + const items: FlyerImportItem[] = parsed.items.map((item) => { + const match = this.matchItem(item, products, aliasToProduct, productById); + return { + rawName: item.rawName, + normalizedName: item.normalizedName, + category: item.category, + price: item.price, + priceUnit: item.priceUnit, + comparisonPrice: item.comparisonPrice, + comparisonUnit: item.comparisonUnit, + offerText: item.offerText, + 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, + }; + }); + + return { + retailer: parsed.retailer, + parserVersion: parsed.parserVersion, + items, + warnings: parsed.warnings, + }; + } + + private matchItem( + item: FlyerParseItem, + products: ProductLite[], + aliasToProduct: Map, + productById: Map, + ): { + product: ProductLite | null; + via: FlyerImportMatchVia; + confidence: number; + reasons: string[]; + } { + const normalized = normalizeName(item.rawName || item.normalizedName); + if (!normalized) { + return { product: null, via: 'none', confidence: 0, reasons: ['empty_name'] }; + } + + const aliasedProductId = aliasToProduct.get(normalized); + if (aliasedProductId) { + const product = productById.get(aliasedProductId) ?? null; + return { + product, + via: product ? 'alias' : 'none', + confidence: product ? 1 : 0, + reasons: product ? ['alias_exact'] : ['alias_points_to_missing_product'], + }; + } + + for (const product of products) { + const pn = normalizeName(product.name); + const cn = product.canonicalName ? normalizeName(product.canonicalName) : null; + if (normalized === pn || (cn && normalized === cn)) { + return { + product, + via: 'exact', + confidence: 0.96, + reasons: ['normalized_exact'], + }; + } + } + + let best: { product: ProductLite; confidence: number; overlap: number } | null = null; + const itemTokens = this.tokenize(item.rawName); + for (const product of products) { + const productTokens = this.tokenize(product.canonicalName ?? product.name); + const overlap = this.tokenOverlap(itemTokens, productTokens); + if (overlap <= 0) continue; + const confidence = Math.min(0.92, 0.5 + overlap * 0.4); + if (!best || confidence > best.confidence) { + best = { product, confidence, overlap }; + } + } + + if (best && best.confidence >= 0.66) { + return { + product: best.product, + via: 'token', + confidence: best.confidence, + reasons: [`token_overlap:${best.overlap.toFixed(2)}`], + }; + } + + return { + product: null, + via: 'none', + confidence: 0, + reasons: ['no_match'], + }; + } + + private tokenize(value: string): string[] { + return value + .toLowerCase() + .split(/[^a-z0-9åäö]+/) + .map((part) => part.trim()) + .filter((part) => part.length >= 3); + } + + private tokenOverlap(a: string[], b: string[]): number { + if (a.length === 0 || b.length === 0) return 0; + const as = new Set(a); + const bs = new Set(b); + let intersection = 0; + for (const token of as) { + if (bs.has(token)) intersection++; + } + const union = new Set([...as, ...bs]).size; + if (union === 0) return 0; + return intersection / union; + } + + private async parseViaImporter(file: Express.Multer.File): Promise { + const form = new FormData(); + form.append( + 'file', + new Blob([new Uint8Array(file.buffer)], { type: file.mimetype }), + file.originalname, + ); + form.append('retailer', 'willys'); + + let response: Response; + try { + response = await fetch(`${IMPORTER_SERVICE_URL}/api/flyer/parse`, { + method: 'POST', + body: form, + }); + } catch (err) { + this.logger.error(`Kunde inte nå importer-api för flyer-parse: ${String(err)}`); + throw new ServiceUnavailableException('Importer-tjänsten är inte tillgänglig just nu.'); + } + + if (!response.ok) { + let message = `Importer-tjänsten svarade ${response.status}`; + try { + const body = (await response.json()) as { message?: string }; + if (typeof body.message === 'string' && body.message.trim()) { + message = body.message; + } + } catch { + // ignore parse issues + } + + if (response.status >= 400 && response.status < 500) { + throw new BadRequestException(message); + } + throw new ServiceUnavailableException(message); + } + + return response.json() as Promise; + } +}