import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; import { FlatCategory } from '../categories/categories.service'; const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'; export const AI_CATEGORIZATION_MODEL = 'mistral-small-2603'; const MODEL = AI_CATEGORIZATION_MODEL; export type CategorySuggestion = { categoryId: number; categoryName: string; path: string; confidence: 'high' | 'medium' | 'low'; usedFallback: boolean; }; @Injectable() export class AiService { private readonly logger = new Logger(AiService.name); async suggestCategory( productName: string, categories: FlatCategory[], ): Promise { const apiKey = process.env.MISTRAL_API_KEY; if (!apiKey) { throw new ServiceUnavailableException('MISTRAL_API_KEY är inte konfigurerad i miljövariabler'); } const categoryList = categories .map((c) => `[${c.id}] ${c.path}`) .join('\n'); const systemPrompt = `Du är ett kategoriseringssystem för en livsmedelsapp. Din uppgift är att hitta den mest lämpliga kategorin för en produkt. Tillgängliga kategorier (format: [id] Sökväg): ${categoryList} Regler: 1. Välj den mest specifika underkategorin som passar produkten. 2. Om ingen specifik kategori passar, välj en underkategori under "Övrigt" om möjligt. 3. Om ingen underkategori under "Övrigt" passar, välj "Övrigt" (den kategori vars sökväg är exakt "Övrigt"). 4. Du MÅSTE alltid returnera ett svar — aldrig null eller tomt. 5. Svara ENDAST med giltig JSON i detta format: { "categoryId": , "confidence": "high" | "medium" | "low" } - "high": uppenbart matchande kategori - "medium": trolig matchning - "low": osäker, används fallback (Övrigt eller underkategori till Övrigt)`; const userPrompt = `Produkt: "${productName}"`; 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.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'); } } // Parsa och validera AI-svaret let parsed: { categoryId: number; confidence: string }; try { parsed = JSON.parse(raw); } catch { this.logger.warn(`AI returnerade ogiltig JSON: ${raw}`); return this.fallbackToOvrigt(categories); } const validId = typeof parsed.categoryId === 'number'; const matchedCategory = validId ? categories.find((c) => c.id === parsed.categoryId) : null; if (!matchedCategory) { this.logger.warn(`AI returnerade okänt categoryId ${parsed.categoryId}, använder fallback`); return this.fallbackToOvrigt(categories); } const confidence = ['high', 'medium', 'low'].includes(parsed.confidence) ? (parsed.confidence as 'high' | 'medium' | 'low') : 'medium'; return { categoryId: matchedCategory.id, categoryName: matchedCategory.name, path: matchedCategory.path, confidence, usedFallback: confidence === 'low', }; } private fallbackToOvrigt(categories: FlatCategory[]): CategorySuggestion { const ovrigt = categories.find((c) => c.path === 'Övrigt'); if (!ovrigt) { // Sista utväg — returnera första kategorin const first = categories[0]; return { categoryId: first.id, categoryName: first.name, path: first.path, confidence: 'low', usedFallback: true }; } return { categoryId: ovrigt.id, categoryName: ovrigt.name, path: ovrigt.path, confidence: 'low', usedFallback: true, }; } }