Files
recipe-app/backend/src/ai/ai.service.ts
T
Nils-Johan Gynther 853e853e5e
Test Suite / test (24.15.0) (push) Has been cancelled
feat: enhance ingredient matching rules with keyword matching and add chocolate detection
2026-05-08 17:36:06 +02:00

292 lines
11 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');
}
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}
KWICK-MATCHNING (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
- 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
Regler:
1. Använd KEYWORD-MATCHNING 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": keyword-match ELLER 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';
// 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,
};
}
}