Files
recipe-app/backend/src/ai/ai.service.ts
T
2026-05-11 10:35:30 +02:00

350 lines
13 KiB
TypeScript

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-tiny';
const MODEL = AI_CATEGORIZATION_MODEL;
export type CategorySuggestion = {
categoryId: number;
categoryName: string;
path: string;
confidence: 'high' | 'medium' | 'low';
usedFallback: boolean;
};
export type AiIngredientMatchSuggestion = {
productId: number;
reason?: string;
confidence: 'high' | 'medium' | 'low';
};
export type AiSubstitutionSuggestion = {
productId: number;
reason?: string;
confidence: 'high' | 'medium' | 'low';
};
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
async suggestIngredientMatches(
rawIngredient: string,
candidates: Array<{ id: number; name: string; canonicalName?: string | null }>,
): Promise<AiIngredientMatchSuggestion[]> {
const apiKey = process.env.MISTRAL_API_KEY;
if (!apiKey || candidates.length === 0) return [];
const candidateList = candidates
.map((c) => `[${c.id}] ${c.canonicalName || c.name}`)
.join('\n');
const systemPrompt = `Du matchar en ingrediensrad mot produktkandidater.
Svara ENDAST med JSON: {"matches":[{"productId":123,"reason":"...","confidence":"high|medium|low"}]}
Regler:
1. Välj max 3 kandidater.
2. Om inget passar, returnera tom lista.`;
const userPrompt = `Ingrediens: "${rawIngredient}"\nKandidater:\n${candidateList}`;
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: 300,
temperature: 0.1,
response_format: { type: 'json_object' },
}),
});
if (!response.ok) {
this.logger.warn(`suggestIngredientMatches API-fel: ${response.status}`);
return [];
}
const data = (await response.json()) as { choices: { message: { content: string } }[] };
const raw = data.choices?.[0]?.message?.content ?? '{}';
const parsed = JSON.parse(raw) as { matches?: AiIngredientMatchSuggestion[] };
return Array.isArray(parsed.matches) ? parsed.matches.slice(0, 3) : [];
} catch (err) {
this.logger.warn(`suggestIngredientMatches misslyckades: ${String(err)}`);
return [];
}
}
async suggestSubstitutions(
rawIngredient: string,
availableProducts: Array<{ id: number; name: string; canonicalName?: string | null }>,
): Promise<AiSubstitutionSuggestion[]> {
const apiKey = process.env.MISTRAL_API_KEY;
if (!apiKey || availableProducts.length === 0) return [];
const productList = availableProducts
.map((p) => `[${p.id}] ${p.canonicalName || p.name}`)
.join('\n');
const systemPrompt = `Du föreslår ersättningsvaror för en ingrediens.
Svara ENDAST med JSON: {"substitutions":[{"productId":123,"reason":"...","confidence":"high|medium|low"}]}
Regler:
1. Välj max 3 ersättningar.
2. Om inget passar, returnera tom lista.`;
const userPrompt = `Ingrediens: "${rawIngredient}"\nTillgängliga produkter:\n${productList}`;
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: 300,
temperature: 0.2,
response_format: { type: 'json_object' },
}),
});
if (!response.ok) {
this.logger.warn(`suggestSubstitutions API-fel: ${response.status}`);
return [];
}
const data = (await response.json()) as { choices: { message: { content: string } }[] };
const raw = data.choices?.[0]?.message?.content ?? '{}';
const parsed = JSON.parse(raw) as { substitutions?: AiSubstitutionSuggestion[] };
return Array.isArray(parsed.substitutions) ? parsed.substitutions.slice(0, 3) : [];
} catch (err) {
this.logger.warn(`suggestSubstitutions misslyckades: ${String(err)}`);
return [];
}
}
async suggestCategory(
productName: string,
categories: FlatCategory[],
): Promise<CategorySuggestion> {
const apiKey = process.env.MISTRAL_API_KEY;
if (!apiKey) {
throw new ServiceUnavailableException('MISTRAL_API_KEY är inte konfigurerad i miljövariabler');
}
// Snabb deterministic guard för välkända äppelsorter.
// Detta minskar felklassning när "äpple" saknas i namnet (t.ex. Granny Smith).
const appleCategory = this.matchAppleVarietyCategory(productName, categories);
if (appleCategory) {
return appleCategory;
}
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}
PRIORITERINGSORDNING (måste följas):
1. Regelmatch (keyword + sortlexikon)
2. Synonym/semantisk match inom livsmedelsdomän
3. Fallback enligt regler nedan
SNABBMATCHNING (gör detta först):
- Kött/fläsk: Sök efter ord som "fläsk", "flaskytterfile", "bacon", "kotlett", "karré" → Kött, chark & fågel > Fläsk
- Fågel: Sök efter "kyckling", "kalkon", "drumstick", "filé" → Kött, chark & fågel > Fågel
- Äpplen/fruktsorter: Sök efter "äpple", "apple", "granny smith", "pink lady", "royal gala", "golden delicious", "jonagold", "fuji", "braeburn", "aroma", "red moon" → Frukt & Grönt > Frukt > Äpplen (eller Frukt om Äpplen saknas)
- Choklad/spreads: Sök efter "nutella", "choklad", "kakao", "spreads" → Sötsaker & snacks > Choklad & spreads
- Bröd: Sök efter "bröd", "toast", "brödrost", "limpa" → Bröd & bakvaror > Bröd
DISAMBIGUERING:
- Om ett ord kan vara både varumärke och livsmedelssort, välj livsmedelstolkning ENDAST om ordet finns i sort/keyword-reglerna eller tydligt matchar kategoriträdet.
- Om ingen tydlig livsmedelsmatch finns, gå till fallback istället för att gissa aggressivt.
Regler:
1. Använd SNABBMATCHNING först innan annat.
2. Om ingen keyword-match, välj den mest specifika underkategorin som passar.
3. Om ingen specifik kategori passar, välj en underkategori under "Övrigt" om möjligt.
4. Om ingen underkategori under "Övrigt" passar, välj "Övrigt" (den kategori vars sökväg är exakt "Övrigt").
5. Du MÅSTE alltid returnera ett svar — aldrig null eller tomt.
6. Svara ENDAST med giltig JSON i detta format: { "categoryId": <nummer>, "confidence": "high" | "medium" | "low" }
- "high": regelmatch (keyword/sort) ELLER uppenbart exakt kategoriträff
- "medium": trolig semantisk matchning inom livsmedelsdomän
- "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';
// Guardrail: endast låg konfidens remappas till L1-förälder.
// Medium får behålla sin specifika kategori för att inte tappa precision.
if (confidence === 'low') {
const l1Name = matchedCategory.path.split(' > ')[0];
const l1 = categories.find((c) => c.path === l1Name);
if (l1 && l1.id !== matchedCategory.id) {
this.logger.log(
`AI-guardrail: ${confidence} konfidenspoäng → remappar "${matchedCategory.path}" → L1 "${l1.path}"`,
);
return {
categoryId: l1.id,
categoryName: l1.name,
path: l1.path,
confidence,
usedFallback: true,
};
}
}
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,
};
}
private matchAppleVarietyCategory(
productName: string,
categories: FlatCategory[],
): CategorySuggestion | null {
const normalized = productName.trim().toLowerCase();
const looksLikeApple = /\b(äpple|apple|granny\s*smith|pink\s*lady|royal\s*gala|golden\s*delicious|jonagold|fuji|braeburn|aroma)\b/i
.test(normalized);
if (!looksLikeApple) {
return null;
}
const appleLeaf = categories.find(
(c) => c.path.toLowerCase() === 'frukt & grönt > frukt > äpplen',
);
if (appleLeaf) {
return {
categoryId: appleLeaf.id,
categoryName: appleLeaf.name,
path: appleLeaf.path,
confidence: 'high',
usedFallback: false,
};
}
const fruitFallback = categories.find(
(c) => c.path.toLowerCase() === 'frukt & grönt > frukt',
);
if (fruitFallback) {
return {
categoryId: fruitFallback.id,
categoryName: fruitFallback.name,
path: fruitFallback.path,
confidence: 'high',
usedFallback: false,
};
}
return null;
}
}