9fe85a719c
Test Suite / test (24.15.0) (push) Has been cancelled
- Added RecipeAnalysisService to handle recipe ingredient analysis, including methods for checking ingredient availability and calculating quantities. - Introduced new TypeScript definitions for recipe analysis results, including ingredient status and summary. - Created corresponding Dart models for recipe analysis, including RecipeIngredientAnalysis, RecipeAnalysisSummary, and RecipeShoppingCandidate. - Updated Flutter UI to reflect changes in ingredient availability status. - Fixed color opacity issue in recipe image card.
688 lines
29 KiB
JavaScript
688 lines
29 KiB
JavaScript
"use strict";
|
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
};
|
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
};
|
|
var RecipesService_1;
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.RecipesService = void 0;
|
|
const common_1 = require("@nestjs/common");
|
|
const fs = require("node:fs/promises");
|
|
const path = require("node:path");
|
|
const prisma_service_1 = require("../prisma/prisma.service");
|
|
const ai_service_1 = require("../ai/ai.service");
|
|
const download_image_1 = require("../common/utils/download-image");
|
|
const recipe_parser_1 = require("../common/utils/recipe-parser");
|
|
const units_1 = require("../common/utils/units");
|
|
const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images';
|
|
let RecipesService = RecipesService_1 = class RecipesService {
|
|
constructor(prisma, aiService) {
|
|
this.prisma = prisma;
|
|
this.aiService = aiService;
|
|
this.logger = new common_1.Logger(RecipesService_1.name);
|
|
}
|
|
throwRecipeNotFound(id) {
|
|
throw new common_1.NotFoundException(`Recipe with id ${id} not found`);
|
|
}
|
|
normalizeIngredientName(value) {
|
|
const trimmed = value.trim();
|
|
if (!trimmed)
|
|
return trimmed;
|
|
return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
|
|
}
|
|
async assertProductsActive(productIds) {
|
|
if (productIds.length === 0)
|
|
return;
|
|
const activeProducts = await this.prisma.product.findMany({
|
|
where: { id: { in: productIds }, isActive: true },
|
|
select: { id: true },
|
|
});
|
|
if (activeProducts.length !== productIds.length) {
|
|
const foundIds = new Set(activeProducts.map((p) => p.id));
|
|
const missing = productIds.filter((id) => !foundIds.has(id));
|
|
throw new common_1.BadRequestException(`En eller flera ingrediensprodukter är inaktiva eller finns inte: ${missing.join(', ')}`);
|
|
}
|
|
}
|
|
async findRecipeByIdOrThrow(id) {
|
|
const recipe = await this.prisma.recipe.findUnique({ where: { id } });
|
|
if (!recipe) {
|
|
this.throwRecipeNotFound(id);
|
|
}
|
|
return recipe;
|
|
}
|
|
async assertAndClaimRecipeOwner(recipe, userId) {
|
|
if (recipe.ownerId === null) {
|
|
await this.prisma.recipe.update({
|
|
where: { id: recipe.id },
|
|
data: { ownerId: userId },
|
|
});
|
|
}
|
|
else if (recipe.ownerId !== userId) {
|
|
this.throwRecipeNotFound(recipe.id);
|
|
}
|
|
}
|
|
assertRecipeOwnedByUser(recipe, userId, id) {
|
|
if (recipe.ownerId !== userId) {
|
|
this.throwRecipeNotFound(id);
|
|
}
|
|
}
|
|
async getInventoryPreview(id, userId) {
|
|
const recipe = await this.prisma.recipe.findFirst({
|
|
where: {
|
|
id,
|
|
OR: [
|
|
{ isPublic: true },
|
|
{ ownerId: userId },
|
|
{ shares: { some: { userId } } },
|
|
],
|
|
},
|
|
include: {
|
|
ingredients: {
|
|
include: {
|
|
product: true,
|
|
},
|
|
orderBy: {
|
|
id: 'asc',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (!recipe) {
|
|
throw new common_1.NotFoundException(`Recipe with id ${id} not found`);
|
|
}
|
|
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) => {
|
|
if (!ingredient.productId || !ingredient.product) {
|
|
return {
|
|
ingredientId: ingredient.id,
|
|
productId: null,
|
|
productName: ingredient.rawName || 'Okänd ingrediens',
|
|
requiredQuantity: Number(ingredient.quantity ?? 0),
|
|
requiredUnit: ingredient.unit || '',
|
|
note: ingredient.note,
|
|
availableQuantity: 0,
|
|
availableUnit: ingredient.unit || '',
|
|
matchingInventoryItems: [],
|
|
otherInventoryItems: [],
|
|
status: 'missing',
|
|
fromPantry: false,
|
|
missingQuantity: Number(ingredient.quantity ?? 0),
|
|
};
|
|
}
|
|
const requiredUnit = (ingredient.unit ?? '').trim();
|
|
const requiredQuantity = Number(ingredient.quantity ?? 0);
|
|
const coveredByPantry = pantryProductIds.has(ingredient.productId) ||
|
|
(Array.isArray(ingredient.alternativeProductIds) &&
|
|
ingredient.alternativeProductIds.some((altId) => pantryProductIds.has(altId)));
|
|
if (coveredByPantry) {
|
|
return {
|
|
ingredientId: ingredient.id,
|
|
productId: ingredient.productId,
|
|
productName: ingredient.product.canonicalName || ingredient.product.name,
|
|
requiredQuantity,
|
|
requiredUnit,
|
|
note: ingredient.note,
|
|
availableQuantity: requiredQuantity,
|
|
availableUnit: requiredUnit,
|
|
matchingInventoryItems: [],
|
|
otherInventoryItems: [],
|
|
status: 'enough',
|
|
fromPantry: true,
|
|
missingQuantity: 0,
|
|
};
|
|
}
|
|
const inventoryItems = await this.prisma.inventoryItem.findMany({
|
|
where: {
|
|
productId: {
|
|
in: [
|
|
ingredient.productId,
|
|
...(Array.isArray(ingredient.alternativeProductIds)
|
|
? ingredient.alternativeProductIds
|
|
: []),
|
|
],
|
|
},
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
const sameUnitItems = inventoryItems.filter((item) => requiredUnit
|
|
? item.unit.trim().toLowerCase() === requiredUnit.toLowerCase()
|
|
: true);
|
|
const availableSameUnit = sameUnitItems.reduce((sum, item) => sum + Number(item.quantity), 0);
|
|
const otherUnitItems = inventoryItems.filter((item) => requiredUnit
|
|
? item.unit.trim().toLowerCase() !== requiredUnit.toLowerCase()
|
|
: false);
|
|
let availableOtherUnit = 0;
|
|
for (const item of otherUnitItems) {
|
|
try {
|
|
const convertedQuantity = (0, units_1.convertUnit)(Number(item.quantity), item.unit, requiredUnit);
|
|
availableOtherUnit += convertedQuantity;
|
|
}
|
|
catch {
|
|
}
|
|
}
|
|
const totalAvailable = availableSameUnit + availableOtherUnit;
|
|
let status;
|
|
if (totalAvailable >= requiredQuantity) {
|
|
status = 'enough';
|
|
}
|
|
else if (availableSameUnit === 0 && availableOtherUnit > 0) {
|
|
status = 'unit_mismatch';
|
|
}
|
|
else {
|
|
status = 'missing';
|
|
}
|
|
return {
|
|
ingredientId: ingredient.id,
|
|
productId: ingredient.productId,
|
|
productName: ingredient.product.canonicalName || ingredient.product.name,
|
|
requiredQuantity,
|
|
requiredUnit,
|
|
note: ingredient.note,
|
|
availableQuantity: totalAvailable,
|
|
availableUnit: requiredUnit,
|
|
matchingInventoryItems: sameUnitItems.map((item) => ({
|
|
id: item.id,
|
|
quantity: item.quantity,
|
|
unit: item.unit,
|
|
location: item.location,
|
|
brand: item.brand || null,
|
|
bestBeforeDate: item.bestBeforeDate || null,
|
|
})),
|
|
otherInventoryItems: otherUnitItems.map((item) => {
|
|
const canConvertUnits = requiredUnit ? (0, units_1.canConvert)(item.unit, requiredUnit) : false;
|
|
let convertedQuantity = 0;
|
|
if (canConvertUnits) {
|
|
try {
|
|
convertedQuantity = (0, units_1.convertUnit)(Number(item.quantity), item.unit, requiredUnit);
|
|
}
|
|
catch {
|
|
convertedQuantity = 0;
|
|
}
|
|
}
|
|
return {
|
|
id: item.id,
|
|
quantity: item.quantity,
|
|
unit: item.unit,
|
|
location: item.location,
|
|
convertedQuantity: canConvertUnits ? convertedQuantity : 0,
|
|
canConvert: canConvertUnits,
|
|
};
|
|
}),
|
|
status,
|
|
fromPantry: false,
|
|
missingQuantity: status === 'missing' ? Math.max(0, requiredQuantity - totalAvailable) : 0,
|
|
};
|
|
}));
|
|
const summary = {
|
|
totalIngredients: ingredientPreviews.length,
|
|
enoughCount: ingredientPreviews.filter((i) => i.status === 'enough').length,
|
|
missingCount: ingredientPreviews.filter((i) => i.status === 'missing').length,
|
|
unitMismatchCount: ingredientPreviews.filter((i) => i.status === 'unit_mismatch').length,
|
|
canCookExactly: ingredientPreviews.every((i) => i.status === 'enough'),
|
|
pantryCount: ingredientPreviews.filter((i) => i.fromPantry).length,
|
|
};
|
|
return {
|
|
recipe: {
|
|
id: recipe.id,
|
|
name: recipe.name,
|
|
description: recipe.description,
|
|
},
|
|
ingredients: ingredientPreviews,
|
|
summary,
|
|
};
|
|
}
|
|
async findAll(userId) {
|
|
return this.prisma.recipe.findMany({
|
|
where: {
|
|
OR: [
|
|
{ isPublic: true },
|
|
{ ownerId: userId },
|
|
{ shares: { some: { userId } } },
|
|
],
|
|
},
|
|
include: {
|
|
ingredients: {
|
|
include: {
|
|
product: { include: { nutrition: true } },
|
|
},
|
|
},
|
|
owner: { select: { id: true, username: true } },
|
|
shares: { select: { userId: true } },
|
|
},
|
|
});
|
|
}
|
|
async findOne(id, userId) {
|
|
const recipe = await this.prisma.recipe.findFirst({
|
|
where: {
|
|
id,
|
|
OR: [
|
|
{ isPublic: true },
|
|
{ ownerId: userId },
|
|
{ shares: { some: { userId } } },
|
|
],
|
|
},
|
|
include: {
|
|
ingredients: {
|
|
include: {
|
|
product: { include: { nutrition: true } },
|
|
},
|
|
},
|
|
owner: { select: { id: true, username: true } },
|
|
shares: { select: { userId: true } },
|
|
},
|
|
});
|
|
if (!recipe) {
|
|
throw new common_1.NotFoundException(`Recipe with id ${id} not found`);
|
|
}
|
|
return recipe;
|
|
}
|
|
async update(id, updateRecipeDto, userId) {
|
|
const existingRecipe = await this.findRecipeByIdOrThrow(id);
|
|
await this.assertAndClaimRecipeOwner(existingRecipe, userId);
|
|
await this.assertProductsActive(updateRecipeDto.ingredients
|
|
.map((i) => i.productId)
|
|
.filter((id) => typeof id === 'number'));
|
|
const recipe = await this.prisma.$transaction(async (tx) => {
|
|
await tx.recipeIngredient.deleteMany({ where: { recipeId: id } });
|
|
return tx.recipe.update({
|
|
where: { id },
|
|
data: {
|
|
name: updateRecipeDto.name,
|
|
description: updateRecipeDto.description || null,
|
|
instructions: updateRecipeDto.instructions || null,
|
|
servings: updateRecipeDto.servings ?? null,
|
|
...(updateRecipeDto.isPublic !== undefined && { isPublic: updateRecipeDto.isPublic }),
|
|
...(updateRecipeDto.imageUrl !== undefined && { imageUrl: updateRecipeDto.imageUrl || null }),
|
|
ingredients: {
|
|
create: updateRecipeDto.ingredients.map((ingredient) => ({
|
|
productId: ingredient.productId ?? null,
|
|
rawName: this.normalizeIngredientName(ingredient.rawName),
|
|
rawLine: ingredient.rawLine ?? null,
|
|
quantity: ingredient.quantity ?? null,
|
|
unit: ingredient.unit?.trim() ? ingredient.unit : null,
|
|
note: ingredient.note || null,
|
|
alternativeProductIds: ingredient.alternativeProductIds ?? [],
|
|
matchConfidence: ingredient.matchConfidence ?? null,
|
|
matchSource: ingredient.matchSource ?? null,
|
|
})),
|
|
},
|
|
},
|
|
include: {
|
|
ingredients: {
|
|
include: {
|
|
product: { include: { nutrition: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
return recipe;
|
|
}
|
|
async remove(id, userId) {
|
|
const existingRecipe = await this.findRecipeByIdOrThrow(id);
|
|
await this.assertAndClaimRecipeOwner(existingRecipe, userId);
|
|
await this.prisma.recipeIngredient.deleteMany({ where: { recipeId: id } });
|
|
await this.prisma.recipe.delete({ where: { id } });
|
|
if (existingRecipe.imageUrl?.startsWith('/images/')) {
|
|
const filename = path.basename(existingRecipe.imageUrl);
|
|
const filePath = path.join(IMAGE_DEST_DIR, filename);
|
|
await fs.unlink(filePath).catch(() => {
|
|
});
|
|
}
|
|
}
|
|
async updateImage(id, sourceUrl, userId) {
|
|
const existingRecipe = await this.findRecipeByIdOrThrow(id);
|
|
this.assertRecipeOwnedByUser(existingRecipe, userId, id);
|
|
const imageUrl = await (0, download_image_1.downloadAndOptimizeImage)(sourceUrl, IMAGE_DEST_DIR);
|
|
return this.prisma.recipe.update({
|
|
where: { id },
|
|
data: { imageUrl },
|
|
include: {
|
|
ingredients: { include: { product: { include: { nutrition: true } } } },
|
|
owner: { select: { id: true, username: true } },
|
|
shares: { select: { userId: true } },
|
|
},
|
|
});
|
|
}
|
|
async setVisibility(id, userId, isPublic) {
|
|
const existingRecipe = await this.findRecipeByIdOrThrow(id);
|
|
this.assertRecipeOwnedByUser(existingRecipe, userId, id);
|
|
if (isPublic) {
|
|
const owner = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { canShareRecipes: true },
|
|
});
|
|
if (!owner?.canShareRecipes) {
|
|
throw new common_1.ForbiddenException('Du har inte behörighet att dela recept.');
|
|
}
|
|
}
|
|
return this.prisma.recipe.update({
|
|
where: { id },
|
|
data: { isPublic },
|
|
include: {
|
|
ingredients: { include: { product: { include: { nutrition: true } } } },
|
|
owner: { select: { id: true, username: true } },
|
|
shares: { select: { userId: true } },
|
|
},
|
|
});
|
|
}
|
|
async shareWithUser(id, ownerId, username) {
|
|
const recipe = await this.findRecipeByIdOrThrow(id);
|
|
this.assertRecipeOwnedByUser(recipe, ownerId, id);
|
|
const owner = await this.prisma.user.findUnique({
|
|
where: { id: ownerId },
|
|
select: { canShareRecipes: true },
|
|
});
|
|
if (!owner?.canShareRecipes) {
|
|
throw new common_1.ForbiddenException('Du har inte behörighet att dela recept.');
|
|
}
|
|
const targetUser = await this.prisma.user.findUnique({
|
|
where: { username },
|
|
select: { id: true },
|
|
});
|
|
if (!targetUser) {
|
|
throw new common_1.NotFoundException(`User ${username} not found`);
|
|
}
|
|
if (targetUser.id === ownerId) {
|
|
return this.findOne(id, ownerId);
|
|
}
|
|
await this.prisma.recipeShare.upsert({
|
|
where: { recipeId_userId: { recipeId: id, userId: targetUser.id } },
|
|
create: { recipeId: id, userId: targetUser.id },
|
|
update: {},
|
|
});
|
|
return this.findOne(id, ownerId);
|
|
}
|
|
async unshareWithUser(id, ownerId, username) {
|
|
const recipe = await this.findRecipeByIdOrThrow(id);
|
|
this.assertRecipeOwnedByUser(recipe, ownerId, id);
|
|
const targetUser = await this.prisma.user.findUnique({
|
|
where: { username },
|
|
select: { id: true },
|
|
});
|
|
if (!targetUser) {
|
|
throw new common_1.NotFoundException(`User ${username} not found`);
|
|
}
|
|
await this.prisma.recipeShare.deleteMany({
|
|
where: { recipeId: id, userId: targetUser.id },
|
|
});
|
|
return this.findOne(id, ownerId);
|
|
}
|
|
async create(createRecipeDto, userId) {
|
|
await this.assertProductsActive(createRecipeDto.ingredients
|
|
.map((i) => i.productId)
|
|
.filter((id) => typeof id === 'number'));
|
|
this.logger.log(`[create] Incoming imageUrl from client: ${createRecipeDto.imageUrl ?? 'null'}`);
|
|
let imageUrl = createRecipeDto.imageUrl || null;
|
|
let downloadedImagePath = null;
|
|
if (imageUrl && imageUrl.startsWith('http')) {
|
|
const externalImageUrl = imageUrl;
|
|
try {
|
|
imageUrl = await (0, download_image_1.downloadAndOptimizeImage)(imageUrl, IMAGE_DEST_DIR);
|
|
downloadedImagePath = imageUrl;
|
|
}
|
|
catch (err) {
|
|
console.warn('[RecipesService] Kunde inte ladda ner receptbild:', err);
|
|
imageUrl = externalImageUrl;
|
|
}
|
|
}
|
|
this.logger.log(`[create] Final imageUrl persisted to DB: ${imageUrl ?? 'null'}`);
|
|
try {
|
|
const recipe = await this.prisma.recipe.create({
|
|
data: {
|
|
name: createRecipeDto.name,
|
|
description: createRecipeDto.description || null,
|
|
instructions: createRecipeDto.instructions || null,
|
|
imageUrl,
|
|
servings: createRecipeDto.servings ?? null,
|
|
ownerId: userId,
|
|
isPublic: false,
|
|
ingredients: {
|
|
create: createRecipeDto.ingredients.map((ingredient) => ({
|
|
productId: ingredient.productId ?? null,
|
|
rawName: this.normalizeIngredientName(ingredient.rawName),
|
|
rawLine: ingredient.rawLine ?? null,
|
|
quantity: ingredient.quantity ?? null,
|
|
unit: ingredient.unit?.trim() ? ingredient.unit : null,
|
|
note: ingredient.note || null,
|
|
alternativeProductIds: ingredient.alternativeProductIds ?? [],
|
|
matchConfidence: ingredient.matchConfidence ?? null,
|
|
matchSource: ingredient.matchSource ?? null,
|
|
})),
|
|
},
|
|
},
|
|
include: {
|
|
ingredients: {
|
|
include: {
|
|
product: { include: { nutrition: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
return recipe;
|
|
}
|
|
catch (err) {
|
|
if (downloadedImagePath) {
|
|
await fs.unlink(path.join(IMAGE_DEST_DIR, path.basename(downloadedImagePath))).catch(() => { });
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
async addIngredient(id, ingredient, userId) {
|
|
const recipe = await this.findRecipeByIdOrThrow(id);
|
|
await this.assertRecipeOwnedByUser(recipe, userId, id);
|
|
await this.assertProductsActive([ingredient.productId]);
|
|
return this.prisma.recipeIngredient.create({
|
|
data: {
|
|
productId: ingredient.productId,
|
|
quantity: ingredient.quantity,
|
|
unit: ingredient.unit,
|
|
note: ingredient.note || null,
|
|
recipeId: id,
|
|
},
|
|
include: {
|
|
product: { include: { nutrition: true } },
|
|
},
|
|
});
|
|
}
|
|
async suggestRecipesFromInventory(userId) {
|
|
const inventoryItems = await this.prisma.inventoryItem.findMany({
|
|
include: { product: { select: { canonicalName: true, name: true } } },
|
|
orderBy: { bestBeforeDate: 'asc' },
|
|
});
|
|
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: [] };
|
|
}
|
|
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();
|
|
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);
|
|
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) {
|
|
const importerUrl = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
|
|
let parsed;
|
|
try {
|
|
const response = await fetch(`${importerUrl}/api/recipes/parse-markdown`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ markdown: dto.markdown }),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Importer svarade ${response.status}`);
|
|
}
|
|
parsed = (await response.json());
|
|
}
|
|
catch (err) {
|
|
this.logger.error(`Kunde inte nå importer-api för parse-markdown: ${err}`);
|
|
parsed = (0, recipe_parser_1.parseRecipeMarkdown)(dto.markdown);
|
|
}
|
|
const allProducts = await this.prisma.product.findMany({
|
|
where: { isActive: true },
|
|
select: { id: true, name: true, canonicalName: true, normalizedName: true },
|
|
});
|
|
const normalize = (s) => s.toLowerCase().trim().replace(/[^a-zåäö0-9\s]/gi, '').replace(/\s+/g, ' ');
|
|
const levenshtein = (a, b) => {
|
|
const m = a.length;
|
|
const n = b.length;
|
|
const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
|
|
for (let i = 1; i <= m; i++) {
|
|
for (let j = 1; j <= n; j++) {
|
|
dp[i][j] =
|
|
a[i - 1] === b[j - 1]
|
|
? dp[i - 1][j - 1]
|
|
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
}
|
|
}
|
|
return dp[m][n];
|
|
};
|
|
const ingredientsWithSuggestions = parsed.ingredients.map((ingredient) => {
|
|
const alternatives = ingredient.alternatives?.length > 1
|
|
? ingredient.alternatives
|
|
: [ingredient.rawName];
|
|
const scoreProduct = (query) => allProducts
|
|
.map((product) => {
|
|
const targetName = normalize(product.canonicalName || product.name);
|
|
const targetNormalized = normalize(product.normalizedName);
|
|
if (targetNormalized === query || targetName === query) {
|
|
return { product, score: 100 };
|
|
}
|
|
if (targetName.includes(query) || query.includes(targetName)) {
|
|
return { product, score: 70 };
|
|
}
|
|
const dist = levenshtein(query, targetName);
|
|
const maxLen = Math.max(query.length, targetName.length);
|
|
const similarity = maxLen === 0 ? 100 : Math.round((1 - dist / maxLen) * 100);
|
|
return { product, score: similarity };
|
|
})
|
|
.filter((s) => s.score >= 40)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 5);
|
|
const seenIds = new Set();
|
|
const scored = alternatives
|
|
.flatMap((alt) => scoreProduct(normalize(alt)))
|
|
.filter((s) => {
|
|
if (seenIds.has(s.product.id))
|
|
return false;
|
|
seenIds.add(s.product.id);
|
|
return true;
|
|
})
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 5)
|
|
.map((s) => ({
|
|
productId: s.product.id,
|
|
productName: s.product.canonicalName || s.product.name,
|
|
score: s.score,
|
|
}));
|
|
return {
|
|
rawName: ingredient.rawName,
|
|
rawLine: ingredient.rawName,
|
|
alternatives: ingredient.alternatives ?? [],
|
|
quantity: ingredient.quantity,
|
|
unit: ingredient.unit,
|
|
note: ingredient.note,
|
|
suggestions: scored,
|
|
};
|
|
});
|
|
return {
|
|
name: parsed.name,
|
|
description: parsed.description,
|
|
instructions: parsed.instructions,
|
|
ingredients: ingredientsWithSuggestions,
|
|
};
|
|
}
|
|
};
|
|
exports.RecipesService = RecipesService;
|
|
exports.RecipesService = RecipesService = RecipesService_1 = __decorate([
|
|
(0, common_1.Injectable)(),
|
|
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
|
ai_service_1.AiService])
|
|
], RecipesService);
|
|
//# sourceMappingURL=recipes.service.js.map
|