diff --git a/backend/src/recipes/recipes.controller.ts b/backend/src/recipes/recipes.controller.ts index 26999cc5..77ebabfa 100644 --- a/backend/src/recipes/recipes.controller.ts +++ b/backend/src/recipes/recipes.controller.ts @@ -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); diff --git a/backend/src/recipes/recipes.module.ts b/backend/src/recipes/recipes.module.ts index cf460315..765751a6 100644 --- a/backend/src/recipes/recipes.module.ts +++ b/backend/src/recipes/recipes.module.ts @@ -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], }) diff --git a/backend/src/recipes/recipes.service.ts b/backend/src/recipes/recipes.service.ts index 0b0b9409..1dd8cbd4 100644 --- a/backend/src/recipes/recipes.service.ts +++ b/backend/src/recipes/recipes.service.ts @@ -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'; diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 6169183d..4850089d 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -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 { diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index a43d1456..46ac5e24 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -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((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) { diff --git a/flutter/lib/features/recipes/domain/inventory_preview.dart b/flutter/lib/features/recipes/domain/inventory_preview.dart index bf49cff1..edee97d9 100644 --- a/flutter/lib/features/recipes/domain/inventory_preview.dart +++ b/flutter/lib/features/recipes/domain/inventory_preview.dart @@ -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 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 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, ); } } diff --git a/flutter/lib/features/recipes/presentation/ai_recipe_suggestions_screen.dart b/flutter/lib/features/recipes/presentation/ai_recipe_suggestions_screen.dart new file mode 100644 index 00000000..90e01f06 --- /dev/null +++ b/flutter/lib/features/recipes/presentation/ai_recipe_suggestions_screen.dart @@ -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 mainIngredients; + final List 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 json) { + return _AiSuggestion( + name: json['name'] as String? ?? '', + description: json['description'] as String? ?? '', + mainIngredients: (json['mainIngredients'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + [], + missingIngredients: (json['missingIngredients'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + [], + estimatedTime: json['estimatedTime'] as String? ?? '', + ); + } +} + +class AiRecipeSuggestionsScreen extends ConsumerStatefulWidget { + const AiRecipeSuggestionsScreen({super.key}); + + @override + ConsumerState createState() => + _AiRecipeSuggestionsScreenState(); +} + +class _AiRecipeSuggestionsScreenState + extends ConsumerState { + bool _isLoading = false; + String? _error; + List<_AiSuggestion> _suggestions = []; + bool _hasFetched = false; + + Future _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; + final list = (raw['suggestions'] as List?) ?? []; + setState(() { + _suggestions = + list.map((e) => _AiSuggestion.fromJson(e as Map)).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'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart b/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart index e95e4331..33f44655 100644 --- a/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart +++ b/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart @@ -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), diff --git a/flutter/lib/features/recipes/presentation/recipes_screen.dart b/flutter/lib/features/recipes/presentation/recipes_screen.dart index 9c4c1780..d558c3ab 100644 --- a/flutter/lib/features/recipes/presentation/recipes_screen.dart +++ b/flutter/lib/features/recipes/presentation/recipes_screen.dart @@ -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), ),