From 4d2942a8e5bd680c71dfb7eb8f416a705e160b7a Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Tue, 19 May 2026 20:13:59 +0200 Subject: [PATCH] chore(infra): add AI flyer parsing configuration and retry logic - Add FLYER_AI_TIMEOUT_MS and FLYER_AI_RETRIES environment variables - Configure timeout and retry settings in compose.yml - Update AiFlyerParserService with configurable timeout and retry logic - Add text window reduction strategy for retry attempts - Update documentation in TEKNISK_BESKRIVNING.md - Fix ESLint configuration in app.security.spec.ts --- .env | 2 + .env.example | 8 +- TEKNISK_BESKRIVNING.md | 5 + backend/src/app.security.spec.ts | 6 +- .../services/ai-flyer-parser.service.ts | 113 +++++++++++++----- compose.yml | 2 + 6 files changed, 99 insertions(+), 37 deletions(-) diff --git a/.env b/.env index 13679dd8..f40315ff 100644 --- a/.env +++ b/.env @@ -19,3 +19,5 @@ SEED_USER2_PASSWORD=Test-Anv2-FBG AUTH_SECRET=WheqAss4F/al9yRZRqepJEBs6TzPsN3brX0iBiF4Oww= JWT_SECRET=uK9yRQpyyWOcHYcYbpAdsJ7NJcEsyCYZcgF82OnBz2k= MISTRAL_API_KEY=JGPjLuNnzaLSYMxKbexLZohUOegrSLye +FLYER_AI_TIMEOUT_MS=45000 +FLYER_AI_RETRIES=2 diff --git a/.env.example b/.env.example index 5a845101..a9477b3e 100644 --- a/.env.example +++ b/.env.example @@ -16,9 +16,11 @@ AUTH_SECRET=byt-ut-mig # OBS: Appen vägrar starta om detta saknas. JWT_SECRET=byt-ut-mig -# Mistral AI -# Hämtas från: https://console.mistral.ai/ -MISTRAL_API_KEY= +# Mistral AI +# Hämtas från: https://console.mistral.ai/ +MISTRAL_API_KEY= +FLYER_AI_TIMEOUT_MS=45000 +FLYER_AI_RETRIES=2 # Publik URL (används av frontend) NEXT_PUBLIC_APP_URL=https://recept.gynther.se diff --git a/TEKNISK_BESKRIVNING.md b/TEKNISK_BESKRIVNING.md index b1daddbb..064573e2 100644 --- a/TEKNISK_BESKRIVNING.md +++ b/TEKNISK_BESKRIVNING.md @@ -18,6 +18,11 @@ Se även: README.md för användarflöde, och AI-FUNKTIONER.md för AI-detaljer. # Nyheter och förbättringar (2026-05-18) +- **Flyerimport intern i recipe-api:** `/api/flyer-import/parse` använder nu en intern pipeline i backend (`TextExtractorService` + `AiFlyerParserService` + `FlyerNormalizerService`) och är inte längre beroende av `importer-api`. +- **Textutvinning för flyer:** PDF tolkas primärt med `pdf-parse`; vid bildfiler eller skannad PDF-fallback används OCR via `tesseract.js`. +- **AI-parse för flyer:** Mistral Tiny används för strukturerad extraktion av flyer-rader till JSON, följt av normalisering av pris/enhet/kategori. +- **Timeout/retry-härdning för flyer-AI:** `AiFlyerParserService` har konfigurerbar timeout (`FLYER_AI_TIMEOUT_MS`, default 30000 ms) och retry med successivt kortare textfönster (`FLYER_AI_RETRIES`, default 2) för att minska 503 vid långsamma modellanrop. + - **Backend linting i CI:** ESLint är infört för backend (`backend/eslint.config.mjs`, `npm run lint`) och körs i `.github/workflows/test.yml`. - **Flutter lint-konfiguration:** `flutter/analysis_options.yaml` är tillagd och inkluderar `package:flutter_lints/flutter.yaml`. - **Prisma query logging (miljöstyrd):** `PrismaService` konfigurerar loggnivåer via env-variabeln `PRISMA_LOG_QUERIES`. diff --git a/backend/src/app.security.spec.ts b/backend/src/app.security.spec.ts index 48eb0cb7..6df3f5e6 100644 --- a/backend/src/app.security.spec.ts +++ b/backend/src/app.security.spec.ts @@ -4,12 +4,12 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { RolesGuard } from './auth/roles.guard'; -describe('App security configuration', () => { +describe('App security configuration', () => { function getAppModuleClass() { process.env.JWT_SECRET = process.env.JWT_SECRET ?? 'test-secret'; - // eslint-disable-next-line @typescript-eslint/no-var-requires + // eslint-disable-next-line global-require return require('./app.module').AppModule as any; - } + } it('har globala guards i förväntad ordning: Throttler -> Jwt -> Roles', () => { const AppModule = getAppModuleClass(); 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 39bd5aea..d3dcbaa3 100644 --- a/backend/src/flyer-import/services/ai-flyer-parser.service.ts +++ b/backend/src/flyer-import/services/ai-flyer-parser.service.ts @@ -21,7 +21,8 @@ export interface AiFlyerParseResult { @Injectable() export class AiFlyerParserService { private readonly logger = new Logger(AiFlyerParserService.name); - private readonly timeoutMs = 15_000; + private readonly timeoutMs: number; + private readonly maxRetries: number; private mistral: any; private apiKey: string; @@ -30,6 +31,9 @@ export class AiFlyerParserService { if (!this.apiKey) { throw new Error('MISTRAL_API_KEY environment variable not set'); } + + this.timeoutMs = this.readPositiveIntEnv('FLYER_AI_TIMEOUT_MS', 30_000); + this.maxRetries = this.readPositiveIntEnv('FLYER_AI_RETRIES', 2); } private async getClient(): Promise { @@ -50,38 +54,58 @@ export class AiFlyerParserService { throw new BadRequestException('Flyer-texten är tom. Kan inte fortsätta.'); } - const prompt = this.buildPrompt(text); - try { - this.logger.debug('Sending request to Mistral Tiny'); - const client = await this.getClient(); - const response = await this.withTimeout( - client.chat({ - model: 'mistral-tiny', - messages: [{ role: 'user', content: prompt }], - temperature: 0.1, - }), - this.timeoutMs, - 'Mistral-anrop timeout', - ); + const textWindows = [5000, 3000, 2000]; + const attempts = Math.max(1, Math.min(this.maxRetries + 1, textWindows.length)); + let lastError: unknown = null; - const content = response.choices?.[0]?.message?.content; - if (!content) { - throw new BadRequestException('Tomt svar från AI-modellen.'); + for (let i = 0; i < attempts; i++) { + const window = textWindows[i]; + const prompt = this.buildPrompt(text, window); + + try { + this.logger.debug( + `Sending request to Mistral Tiny (attempt ${i + 1}/${attempts}, timeout=${this.timeoutMs}ms, textWindow=${window})`, + ); + + const response = await this.withTimeout( + client.chat({ + model: 'mistral-tiny', + messages: [{ role: 'user', content: prompt }], + temperature: 0.1, + }), + this.timeoutMs, + 'Mistral-anrop timeout', + ); + + const content = response.choices?.[0]?.message?.content; + if (!content) { + throw new BadRequestException('Tomt svar från AI-modellen.'); + } + + this.logger.debug(`Mistral response length: ${content.length} chars`); + + const jsonString = this.sanitizeJsonResponse(content); + const items = JSON.parse(jsonString) as Array>; + + if (!Array.isArray(items)) { + throw new BadRequestException('AI returnerade inte en JSON-array.'); + } + + return items.map((item, idx) => this.normalizeAiItem(item, idx)); + } catch (attemptErr) { + lastError = attemptErr; + if (!this.isRetryableError(attemptErr) || i === attempts - 1) { + throw attemptErr; + } + this.logger.warn( + `Mistral attempt ${i + 1} failed (${this.toErrorMessage(attemptErr)}). Retrying with shorter text window.`, + ); + } } - this.logger.debug(`Mistral response length: ${content.length} chars`); - - // Rensa och parse JSON - const jsonString = this.sanitizeJsonResponse(content); - const items = JSON.parse(jsonString) as Array>; - - if (!Array.isArray(items)) { - throw new BadRequestException('AI returnerade inte en JSON-array.'); - } - - return items.map((item, idx) => this.normalizeAiItem(item, idx)); + throw lastError instanceof Error ? lastError : new ServiceUnavailableException('AI-anrop misslyckades'); } catch (err) { if (err instanceof SyntaxError) { this.logger.error(`JSON parse error: ${String(err)}`); @@ -121,9 +145,8 @@ export class AiFlyerParserService { /** * Bygger systemprompten för Mistral. */ - private buildPrompt(text: string): string { - // Trunkera långt text för att spara tokens - const truncatedText = text.length > 5000 ? text.substring(0, 5000) : text; + private buildPrompt(text: string, maxTextLength: number): string { + const truncatedText = text.length > maxTextLength ? text.substring(0, maxTextLength) : text; return `Du är en expert på att tolka svenska matvaruflyers (t.ex. från Willys, Coop, ICA). @@ -231,4 +254,32 @@ Exempel på utdata: .replace(/\s+/g, ' ') .trim(); } + + private readPositiveIntEnv(key: string, fallback: number): number { + const raw = process.env[key]; + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + this.logger.warn(`Invalid ${key} value: "${raw}". Falling back to ${fallback}.`); + return fallback; + } + return parsed; + } + + private isRetryableError(err: unknown): boolean { + if (err instanceof ServiceUnavailableException) return true; + const message = this.toErrorMessage(err).toLowerCase(); + return ( + message.includes('timeout') || + message.includes('timed out') || + message.includes('rate limit') || + message.includes('econnreset') || + message.includes('socket hang up') + ); + } + + private toErrorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); + } } diff --git a/compose.yml b/compose.yml index 9aad8ce9..9b657693 100644 --- a/compose.yml +++ b/compose.yml @@ -10,6 +10,8 @@ services: NODE_ENV: "production" DATABASE_URL: "mysql://root:${MARIADB_ROOT_PASSWORD}@recipe-db:3306/${MARIADB_DATABASE}" MISTRAL_API_KEY: "${MISTRAL_API_KEY:-}" + FLYER_AI_TIMEOUT_MS: "${FLYER_AI_TIMEOUT_MS:-30000}" + FLYER_AI_RETRIES: "${FLYER_AI_RETRIES:-2}" JWT_SECRET: "${JWT_SECRET}" ALLOWED_ORIGIN: "${NEXT_PUBLIC_APP_URL}" ADMIN_NADMIN_PASSWORD: "${ADMIN_NADMIN_PASSWORD}"