diff --git a/backend/src/ai/ai.service.ts b/backend/src/ai/ai.service.ts index 4be5dc89..8ed98ed8 100644 --- a/backend/src/ai/ai.service.ts +++ b/backend/src/ai/ai.service.ts @@ -47,38 +47,56 @@ Regler: const userPrompt = `Produkt: "${productName}"`; - let raw: string; - try { - const response = await fetch(MISTRAL_API_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: MODEL, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt }, - ], - max_tokens: 100, - temperature: 0.1, - response_format: { type: 'json_object' }, - }), - }); + let raw = ''; + const MAX_RETRIES = 3; + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const response = await fetch(MISTRAL_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: MODEL, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + max_tokens: 100, + temperature: 0.1, + response_format: { type: 'json_object' }, + }), + }); - if (!response.ok) { - const err = await response.text(); - this.logger.error(`Mistral API-fel: ${response.status} ${err}`); - throw new ServiceUnavailableException('AI-tjänsten svarade inte korrekt'); + if (response.status === 503 || response.status === 429) { + const err = await response.text(); + this.logger.warn(`Mistral API ${response.status} (försök ${attempt}/${MAX_RETRIES}): ${err}`); + if (attempt < MAX_RETRIES) { + await new Promise((r) => setTimeout(r, attempt * 1500)); + continue; + } + throw new ServiceUnavailableException('AI-tjänsten är tillfälligt otillgänglig, försök igen'); + } + + if (!response.ok) { + const err = await response.text(); + this.logger.error(`Mistral API-fel: ${response.status} ${err}`); + throw new ServiceUnavailableException('AI-tjänsten svarade inte korrekt'); + } + + const data = await response.json() as { choices: { message: { content: string } }[] }; + raw = data.choices?.[0]?.message?.content ?? ''; + break; + } catch (err) { + if (err instanceof ServiceUnavailableException) throw err; + this.logger.error(`Mistral fetch-fel (försök ${attempt}/${MAX_RETRIES}): ${String(err)}`); + if (attempt < MAX_RETRIES) { + await new Promise((r) => setTimeout(r, attempt * 1500)); + continue; + } + throw new ServiceUnavailableException('Kunde inte nå AI-tjänsten'); } - - const data = await response.json() as { choices: { message: { content: string } }[] }; - raw = data.choices?.[0]?.message?.content ?? ''; - } catch (err) { - if (err instanceof ServiceUnavailableException) throw err; - this.logger.error(`Mistral fetch-fel: ${String(err)}`); - throw new ServiceUnavailableException('Kunde inte nå AI-tjänsten'); } // Parsa och validera AI-svaret diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index ffffb90e..715d68b2 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -38,6 +38,33 @@ ${text}`; @Injectable() export class ReceiptImportService { private readonly logger = new Logger(ReceiptImportService.name); + private readonly MAX_RETRIES = 3; + + private async callMistralWithRetry(body: object, apiKey: string, source: string): Promise { + for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) { + const response = await fetch(MISTRAL_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (response.status === 503 || response.status === 429) { + const err = await response.text(); + this.logger.warn(`Mistral ${response.status} (${source}, försök ${attempt}/${this.MAX_RETRIES}): ${err}`); + if (attempt < this.MAX_RETRIES) { + await new Promise((r) => setTimeout(r, attempt * 2000)); + continue; + } + throw new ServiceUnavailableException('Mistral API returnerade ett fel: Tjänsten tillfälligt otillgänglig (503)'); + } + + return response; + } + throw new ServiceUnavailableException('Kunde inte nå Mistral API efter flera försök'); + } constructor( private readonly prisma: PrismaService, @@ -72,30 +99,23 @@ export class ReceiptImportService { apiKey: string, ): Promise { const base64 = buffer.toString('base64'); - const response = await fetch(MISTRAL_API_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: RECEIPT_IMPORT_MODEL, - messages: [ - { - role: 'user', - content: [ - { - type: 'image_url', - image_url: { url: `data:${mimeType};base64,${base64}` }, - }, - { type: 'text', text: IMAGE_PROMPT }, - ], - }, - ], - max_tokens: 2000, - temperature: 0.1, - }), - }); + const response = await this.callMistralWithRetry({ + model: RECEIPT_IMPORT_MODEL, + messages: [ + { + role: 'user', + content: [ + { + type: 'image_url', + image_url: { url: `data:${mimeType};base64,${base64}` }, + }, + { type: 'text', text: IMAGE_PROMPT }, + ], + }, + ], + max_tokens: 2000, + temperature: 0.1, + }, apiKey, 'bild'); return this.extractItemsFromMistralResponse(response, 'bild'); } @@ -120,19 +140,12 @@ export class ReceiptImportService { this.logger.log(`PDF-text extraherad (${pdfText.length} tecken)`); - const response = await fetch(MISTRAL_API_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: RECEIPT_IMPORT_MODEL, - messages: [{ role: 'user', content: TEXT_PROMPT(pdfText) }], - max_tokens: 2000, - temperature: 0.1, - }), - }); + const response = await this.callMistralWithRetry({ + model: RECEIPT_IMPORT_MODEL, + messages: [{ role: 'user', content: TEXT_PROMPT(pdfText) }], + max_tokens: 2000, + temperature: 0.1, + }, apiKey, 'PDF'); return this.extractItemsFromMistralResponse(response, 'PDF'); } diff --git a/frontend/app/api/products/route.ts b/frontend/app/api/products/route.ts index 05c491cd..9add664c 100644 --- a/frontend/app/api/products/route.ts +++ b/frontend/app/api/products/route.ts @@ -1,19 +1,21 @@ -import { NextResponse } from 'next/server'; -import { auth } from '../../../auth'; +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../lib/auth-headers'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; -export const GET = auth(async function GET(req) { - const token = (req.auth as any)?.accessToken as string | undefined; - if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); +export async function GET(req: NextRequest) { + const authHeaders = await getAuthHeaders(); + if (!authHeaders.Authorization) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } const url = new URL(req.url); const query = url.searchParams.toString(); const res = await fetch(`${API_BASE}/api/products${query ? `?${query}` : ''}`, { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders, cache: 'no-store', }); const data = await res.json(); return NextResponse.json(data, { status: res.status }); -}); +}