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';
+1
View File
@@ -46,6 +46,7 @@ class RecipeApiPaths {
static String unshare(int id, String username) => '/recipes/$id/share/${Uri.encodeComponent(username)}';
static String inventoryPreview(int id) => '/recipes/$id/inventory-preview';
static const parseMarkdown = '/recipes/parse-markdown';
static const aiSuggestions = '/recipes/ai-suggestions';
}
class InventoryApiPaths {
+5
View File
@@ -9,6 +9,7 @@ import '../../core/auth/jwt_decoder.dart';
import '../../features/auth/presentation/login_screen.dart';
import '../../features/profile/presentation/profile_screen.dart';
import '../../features/recipes/presentation/create_recipe_screen.dart';
import '../../features/recipes/presentation/ai_recipe_suggestions_screen.dart';
import '../../features/recipes/presentation/recipe_detail_screen.dart';
import '../../features/recipes/presentation/recipe_edit_screen.dart';
import '../../features/recipes/presentation/recipes_screen.dart';
@@ -67,6 +68,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
),
// Detail routes — outside ShellRoute to get full-screen with back button.
// /recipes/create must be listed before /recipes/:id to avoid conflict.
GoRoute(
path: '/recipes/ai-suggestions',
builder: (context, state) => const AiRecipeSuggestionsScreen(),
),
GoRoute(
path: '/recipes/create',
builder: (context, state) {
@@ -10,6 +10,7 @@ class IngredientPreview {
final double availableQuantity;
final IngredientStatus status;
final double missingQuantity;
final bool fromPantry;
const IngredientPreview({
required this.ingredientId,
@@ -21,6 +22,7 @@ class IngredientPreview {
required this.availableQuantity,
required this.status,
required this.missingQuantity,
this.fromPantry = false,
});
factory IngredientPreview.fromJson(Map<String, dynamic> json) {
@@ -40,6 +42,7 @@ class IngredientPreview {
availableQuantity: (json['availableQuantity'] as num? ?? 0).toDouble(),
status: status,
missingQuantity: (json['missingQuantity'] as num? ?? 0).toDouble(),
fromPantry: json['fromPantry'] as bool? ?? false,
);
}
}
@@ -50,6 +53,7 @@ class PreviewSummary {
final int missingCount;
final int unitMismatchCount;
final bool canCookExactly;
final int pantryCount;
const PreviewSummary({
required this.totalIngredients,
@@ -57,6 +61,7 @@ class PreviewSummary {
required this.missingCount,
required this.unitMismatchCount,
required this.canCookExactly,
this.pantryCount = 0,
});
factory PreviewSummary.fromJson(Map<String, dynamic> json) {
@@ -66,6 +71,7 @@ class PreviewSummary {
missingCount: json['missingCount'] as int,
unitMismatchCount: json['unitMismatchCount'] as int,
canCookExactly: json['canCookExactly'] as bool? ?? false,
pantryCount: json['pantryCount'] as int? ?? 0,
);
}
}
@@ -0,0 +1,267 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_providers.dart';
import '../../auth/data/auth_providers.dart';
class _AiSuggestion {
final String name;
final String description;
final List<String> mainIngredients;
final List<String> missingIngredients;
final String estimatedTime;
const _AiSuggestion({
required this.name,
required this.description,
required this.mainIngredients,
required this.missingIngredients,
required this.estimatedTime,
});
factory _AiSuggestion.fromJson(Map<String, dynamic> json) {
return _AiSuggestion(
name: json['name'] as String? ?? '',
description: json['description'] as String? ?? '',
mainIngredients: (json['mainIngredients'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
[],
missingIngredients: (json['missingIngredients'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
[],
estimatedTime: json['estimatedTime'] as String? ?? '',
);
}
}
class AiRecipeSuggestionsScreen extends ConsumerStatefulWidget {
const AiRecipeSuggestionsScreen({super.key});
@override
ConsumerState<AiRecipeSuggestionsScreen> createState() =>
_AiRecipeSuggestionsScreenState();
}
class _AiRecipeSuggestionsScreenState
extends ConsumerState<AiRecipeSuggestionsScreen> {
bool _isLoading = false;
String? _error;
List<_AiSuggestion> _suggestions = [];
bool _hasFetched = false;
Future<void> _generateSuggestions() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
final data = await api.getJson(RecipeApiPaths.aiSuggestions, token: token);
if (!mounted) return;
final raw = data as Map<String, dynamic>;
final list = (raw['suggestions'] as List<dynamic>?) ?? [];
setState(() {
_suggestions =
list.map((e) => _AiSuggestion.fromJson(e as Map<String, dynamic>)).toList();
_hasFetched = true;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = 'Kunde inte hämta förslag. Försök igen.';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('AI-receptförslag'),
leading: BackButton(onPressed: () => context.pop()),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Vad kan jag laga?',
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Baserat på vad du har i ditt lager och skafferi föreslår AI vad du kan laga.',
style: theme.textTheme.bodyMedium
?.copyWith(color: cs.onSurfaceVariant),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _isLoading ? null : _generateSuggestions,
icon: _isLoading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimary,
),
)
: const Icon(Icons.auto_awesome),
label: Text(_isLoading ? 'Genererar förslag...' : 'Generera förslag'),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(
_error!,
style: theme.textTheme.bodySmall?.copyWith(color: cs.error),
),
],
const SizedBox(height: 16),
if (_hasFetched && _suggestions.isEmpty && !_isLoading)
Center(
child: Text(
'Inga förslag hittades. Lägg till fler varor i ditt lager.',
style: theme.textTheme.bodyMedium
?.copyWith(color: cs.onSurfaceVariant),
textAlign: TextAlign.center,
),
)
else
Expanded(
child: ListView.separated(
itemCount: _suggestions.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final s = _suggestions[index];
return _SuggestionCard(
suggestion: s,
onCreateRecipe: () => context.push(
'/recipes/create',
extra: {'markdown': '# ${s.name}\n\n${s.description}'},
),
);
},
),
),
],
),
),
);
}
}
class _SuggestionCard extends StatelessWidget {
final _AiSuggestion suggestion;
final VoidCallback onCreateRecipe;
const _SuggestionCard({
required this.suggestion,
required this.onCreateRecipe,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
suggestion.name,
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
),
if (suggestion.estimatedTime.isNotEmpty)
Chip(
label: Text(suggestion.estimatedTime),
avatar: const Icon(Icons.timer_outlined, size: 16),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
],
),
const SizedBox(height: 8),
Text(
suggestion.description,
style: theme.textTheme.bodyMedium,
),
if (suggestion.mainIngredients.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
'Ingredienser du har:',
style: theme.textTheme.labelMedium
?.copyWith(color: cs.primary),
),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
children: suggestion.mainIngredients
.map((ing) => Chip(
label: Text(ing),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
backgroundColor: cs.primaryContainer,
labelStyle: TextStyle(color: cs.onPrimaryContainer),
))
.toList(),
),
],
if (suggestion.missingIngredients.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Kan behövas:',
style: theme.textTheme.labelMedium
?.copyWith(color: cs.error),
),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
children: suggestion.missingIngredients
.map((ing) => Chip(
label: Text(ing),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
backgroundColor: cs.errorContainer,
labelStyle: TextStyle(color: cs.onErrorContainer),
))
.toList(),
),
],
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
onPressed: onCreateRecipe,
icon: const Icon(Icons.add, size: 18),
label: const Text('Skapa recept'),
),
),
],
),
),
);
}
}
@@ -643,29 +643,33 @@ class _IngredientPreviewRow extends StatelessWidget {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final (icon, color) = switch (ingredient.status) {
IngredientStatus.enough => (Icons.check_circle_outline, cs.primary),
IngredientStatus.unitMismatch => (
Icons.swap_horiz,
cs.tertiary,
),
IngredientStatus.missing => (Icons.cancel_outlined, cs.error),
};
final (icon, color) = ingredient.fromPantry
? (Icons.kitchen_outlined, cs.secondary)
: switch (ingredient.status) {
IngredientStatus.enough => (Icons.check_circle_outline, cs.primary),
IngredientStatus.unitMismatch => (
Icons.swap_horiz,
cs.tertiary,
),
IngredientStatus.missing => (Icons.cancel_outlined, cs.error),
};
final requiredStr =
'${_fmtQty(ingredient.requiredQuantity)} ${ingredient.requiredUnit}'.trim();
final availableStr =
'${_fmtQty(ingredient.availableQuantity)} ${ingredient.requiredUnit}'.trim();
final subtitle = switch (ingredient.status) {
IngredientStatus.enough => 'Tillgängligt: $availableStr',
IngredientStatus.missing => ingredient.availableQuantity > 0
? 'Saknar ${_fmtQty(ingredient.missingQuantity)} ${ingredient.requiredUnit} '
'(har $availableStr)'
: 'Saknas helt',
IngredientStatus.unitMismatch =>
'Annan enhet i lager kontrollera manuellt',
};
final subtitle = ingredient.fromPantry
? 'Finns i skafferiet'
: switch (ingredient.status) {
IngredientStatus.enough => 'Tillgängligt: $availableStr',
IngredientStatus.missing => ingredient.availableQuantity > 0
? 'Saknar ${_fmtQty(ingredient.missingQuantity)} ${ingredient.requiredUnit} '
'(har $availableStr)'
: 'Saknas helt',
IngredientStatus.unitMismatch =>
'Annan enhet i lager kontrollera manuellt',
};
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
@@ -92,11 +92,22 @@ class RecipesScreen extends ConsumerWidget {
}
},
),
Positioned(
right: 16,
bottom: 80,
child: FloatingActionButton.small(
tooltip: 'AI-receptförslag',
heroTag: 'ai_suggestions',
onPressed: () => context.push('/recipes/ai-suggestions'),
child: const Icon(Icons.auto_awesome),
),
),
Positioned(
right: 16,
bottom: 16,
child: FloatingActionButton(
tooltip: context.l10n.recipesNewTooltip,
heroTag: 'new_recipe',
onPressed: () => context.push('/recipes/create'),
child: const Icon(Icons.add),
),