feat: implement AI recipe suggestions; add endpoint and UI for generating suggestions based on inventory
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-05 14:15:28 +02:00
parent 3ea5a4778f
commit ce20b1dd07
9 changed files with 471 additions and 19 deletions
@@ -22,6 +22,11 @@ export class RecipesController {
return this.recipesService.parseMarkdown(dto);
}
@Get('ai-suggestions')
getAiSuggestions(@CurrentUser() user: { userId: number }) {
return this.recipesService.suggestRecipesFromInventory(user.userId);
}
@Get()
findAll(@CurrentUser() user: { userId: number }) {
return this.recipesService.findAll(user.userId);
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { AiModule } from '../ai/ai.module';
import { RecipesController } from './recipes.controller';
import { RecipesService } from './recipes.service';
@Module({
imports: [PrismaModule],
imports: [PrismaModule, AiModule],
controllers: [RecipesController],
providers: [RecipesService],
})
+153 -1
View File
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { PrismaService } from '../prisma/prisma.service';
import { AiService } from '../ai/ai.service';
import { CreateRecipeDto } from './dto/create-recipe.dto';
import { CreateIngredientDto } from './dto/create-ingredient.dto';
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
@@ -12,11 +13,22 @@ import { normalizeUnit, getUnitType, convertUnit, canConvert } from '../common/u
const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images';
export interface AiRecipeSuggestion {
name: string;
description: string;
mainIngredients: string[];
missingIngredients: string[];
estimatedTime: string;
}
@Injectable()
export class RecipesService {
private readonly logger = new Logger(RecipesService.name);
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly aiService: AiService,
) {}
private throwRecipeNotFound(id: number): never {
throw new NotFoundException(`Recipe with id ${id} not found`);
@@ -92,8 +104,41 @@ export class RecipesService {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
// Hämta användarens pantry-produkter (stapelvaror — alltid tillgängliga)
const pantryItems = await this.prisma.pantryItem.findMany({
where: { userId },
select: { productId: true },
});
const pantryProductIds = new Set(pantryItems.map((p) => p.productId));
const ingredientPreviews = await Promise.all(
recipe.ingredients.map(async (ingredient: any) => {
// Täcks ingrediensen av pantry (inkl. alternativ)?
const coveredByPantry =
pantryProductIds.has(ingredient.productId) ||
(Array.isArray(ingredient.alternativeProductIds) &&
ingredient.alternativeProductIds.some((altId: number) =>
pantryProductIds.has(altId),
));
if (coveredByPantry) {
return {
ingredientId: ingredient.id,
productId: ingredient.productId,
productName: ingredient.product.canonicalName || ingredient.product.name,
requiredQuantity: Number(ingredient.quantity),
requiredUnit: ingredient.unit,
note: ingredient.note,
availableQuantity: Number(ingredient.quantity),
availableUnit: ingredient.unit,
matchingInventoryItems: [],
otherInventoryItems: [],
status: 'enough' as const,
fromPantry: true,
missingQuantity: 0,
};
}
const inventoryItems = await this.prisma.inventoryItem.findMany({
where: {
productId: {
@@ -188,6 +233,7 @@ export class RecipesService {
};
}),
status,
fromPantry: false,
missingQuantity: status === 'missing' ? Math.max(0, Number(ingredient.quantity) - totalAvailable) : 0,
};
}),
@@ -199,6 +245,7 @@ export class RecipesService {
missingCount: ingredientPreviews.filter((i: any) => i.status === 'missing').length,
unitMismatchCount: ingredientPreviews.filter((i: any) => i.status === 'unit_mismatch').length,
canCookExactly: ingredientPreviews.every((i: any) => i.status === 'enough'),
pantryCount: ingredientPreviews.filter((i: any) => i.fromPantry).length,
};
return {
@@ -495,6 +542,111 @@ export class RecipesService {
});
}
async suggestRecipesFromInventory(userId: number): Promise<{ suggestions: AiRecipeSuggestion[] }> {
// Hämta inventory-items
const inventoryItems = await this.prisma.inventoryItem.findMany({
include: { product: { select: { canonicalName: true, name: true } } },
orderBy: { bestBeforeDate: 'asc' },
});
// Hämta pantry-items (stapelvaror)
const pantryItems = await this.prisma.pantryItem.findMany({
where: { userId },
include: { product: { select: { canonicalName: true, name: true } } },
});
if (inventoryItems.length === 0 && pantryItems.length === 0) {
return { suggestions: [] };
}
// Bygg ingrediens-sammanfattning
const inventoryLines = inventoryItems.map((item) => {
const name = item.product.canonicalName || item.product.name;
return `- ${item.quantity} ${item.unit} ${name}`;
});
const pantryLines = pantryItems.map((item) => {
const name = item.product.canonicalName || item.product.name;
return `- ${name} (stapelvara, alltid tillgänglig)`;
});
const ingredientSummary = [
inventoryLines.length > 0 ? 'Jag har följande i kylen/skafferiet:' : '',
...inventoryLines,
pantryLines.length > 0 ? '\nStapelvaror (alltid tillgängliga):' : '',
...pantryLines,
]
.filter(Boolean)
.join('\n');
const apiKey = process.env.MISTRAL_API_KEY;
if (!apiKey) {
this.logger.warn('MISTRAL_API_KEY saknas — kan inte generera receptförslag');
return { suggestions: [] };
}
const systemPrompt = `Du är en hjälpsam matlagningsassistent för en svensk livsmedelsapp.
Din uppgift är att föreslå recept baserat på vad användaren har hemma.
Regler:
1. Föreslå 3-5 recept som kan lagas med de tillgängliga ingredienserna.
2. Recepten ska vara realistiska och genomförbara.
3. Det är OK om några få vanliga ingredienser saknas (t.ex. salt, olja, kryddor).
4. Svara ENDAST med giltig JSON i detta exakta format:
{
"suggestions": [
{
"name": "Receptnamn",
"description": "Kort beskrivning på 1-2 meningar",
"mainIngredients": ["ingrediens1", "ingrediens2", "ingrediens3"],
"missingIngredients": ["eventuellt saknad ingrediens"],
"estimatedTime": "30 min"
}
]
}`;
const userPrompt = ingredientSummary;
let raw = '';
try {
const response = await fetch('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'mistral-small-latest',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
max_tokens: 1500,
temperature: 0.7,
response_format: { type: 'json_object' },
}),
});
if (!response.ok) {
this.logger.error(`Mistral API-fel vid receptförslag: ${response.status}`);
return { suggestions: [] };
}
const data = await response.json() as { choices: { message: { content: string } }[] };
raw = data.choices?.[0]?.message?.content ?? '{}';
} catch (err) {
this.logger.error(`Kunde inte nå Mistral för receptförslag: ${err}`);
return { suggestions: [] };
}
try {
const parsed = JSON.parse(raw) as { suggestions?: AiRecipeSuggestion[] };
return { suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions : [] };
} catch {
this.logger.error(`Kunde inte parsa AI-svar för receptförslag: ${raw}`);
return { suggestions: [] };
}
}
async parseMarkdown(dto: ParseMarkdownDto) {
// Delegera markdown-parsning till microservice-importer
const importerUrl = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';