feat: add rematch functionality for recipe ingredients and enhance inventory management
Test Suite / test (24.15.0) (push) Has been cancelled

- Added a new API path for rematching recipe ingredients in `api_paths.dart`.
- Implemented a manual product creation dialog in `inventory_screen.dart` to allow users to create new products directly.
- Integrated the rematch functionality in `recipe_repository.dart` to handle rematching of recipe ingredients.
- Updated the recipe detail screen to include a button for triggering the rematch process.
- Introduced a new `RecipeMatchingService` in the backend to handle ingredient matching logic.
- Added database migration to include `aiEngineEnabled` column in the User table.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Nils-Johan Gynther
2026-05-06 09:20:31 +02:00
parent 9fe85a719c
commit 04b1fc3024
53 changed files with 1420 additions and 652 deletions
+66 -1
View File
@@ -1,12 +1,16 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { canConvert, convertUnit } from '../common/utils/units';
import { AiService } from '../ai/ai.service';
type AnalysisStatus = 'exact_match' | 'covered_by_pantry' | 'substitutable' | 'missing';
@Injectable()
export class RecipeAnalysisService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly aiService: AiService,
) {}
private async getAccessibleRecipe(id: number, userId: number) {
const recipe = await this.prisma.recipe.findFirst({
@@ -66,12 +70,31 @@ export class RecipeAnalysisService {
async analyzeRecipeIngredients(id: number, userId: number) {
const recipe = await this.getAccessibleRecipe(id, userId);
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { aiEngineEnabled: true },
});
const pantryItems = await this.prisma.pantryItem.findMany({
where: { userId },
select: { productId: true },
});
const pantryProductIds = new Set(pantryItems.map((p) => p.productId));
const userInventory = await this.prisma.inventoryItem.findMany({
select: { productId: true },
});
const availableProductIds = new Set<number>([
...pantryItems.map((p) => p.productId),
...userInventory.map((i) => i.productId),
]);
const availableProducts = availableProductIds.size > 0
? await this.prisma.product.findMany({
where: { id: { in: Array.from(availableProductIds) }, isActive: true },
select: { id: true, name: true, canonicalName: true },
})
: [];
const ingredients = await Promise.all(
recipe.ingredients.map(async (ingredient: any) => {
const requiredQuantity = Number(ingredient.quantity ?? 0);
@@ -79,6 +102,25 @@ export class RecipeAnalysisService {
const rawName = (ingredient.rawName ?? '').trim() || 'Okänd ingrediens';
if (!ingredient.productId || !ingredient.product) {
const aiMatches = user?.aiEngineEnabled ? await this.aiService.suggestIngredientMatches(rawName, availableProducts) : [];
const aiBest = aiMatches[0];
if (aiBest) {
const matched = availableProducts.find((p) => p.id === aiBest.productId);
return {
ingredientId: ingredient.id,
rawName,
quantity: requiredQuantity,
unit: requiredUnit,
note: ingredient.note ?? null,
status: 'substitutable' as AnalysisStatus,
matchedProductId: aiBest.productId,
matchedProductName: matched?.canonicalName || matched?.name || null,
source: 'ai_match',
availableQuantity: 0,
missingQuantity: requiredQuantity,
};
}
return {
ingredientId: ingredient.id,
rawName,
@@ -185,6 +227,25 @@ export class RecipeAnalysisService {
}
}
const aiSubs = user?.aiEngineEnabled ? await this.aiService.suggestSubstitutions(rawName, availableProducts) : [];
const aiBestSub = aiSubs[0];
if (aiBestSub) {
const aiProduct = availableProducts.find((p) => p.id === aiBestSub.productId);
return {
ingredientId: ingredient.id,
rawName,
quantity: requiredQuantity,
unit: requiredUnit,
note: ingredient.note ?? null,
status: 'substitutable' as AnalysisStatus,
matchedProductId: aiBestSub.productId,
matchedProductName: aiProduct?.canonicalName || aiProduct?.name || null,
source: 'ai_substitute',
availableQuantity,
missingQuantity: Math.max(0, requiredQuantity - availableQuantity),
};
}
return {
ingredientId: ingredient.id,
rawName,
@@ -225,4 +286,8 @@ export class RecipeAnalysisService {
shoppingListCandidates,
};
}
async rematchRecipeIngredients(id: number, userId: number) {
return this.analyzeRecipeIngredients(id, userId);
}
}
@@ -0,0 +1,96 @@
import { Injectable } from '@nestjs/common';
export type ProductMatchCandidate = {
id: number;
name: string;
canonicalName: string | null;
normalizedName: string;
};
export type IngredientSuggestion = {
productId: number;
productName: string;
score: number;
};
@Injectable()
export class RecipeMatchingService {
private normalize(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/[^a-zåäö0-9\s]/gi, '')
.replace(/\s+/g, ' ');
}
private levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
const dp: number[][] = 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];
}
private scoreProducts(query: string, products: ProductMatchCandidate[]) {
const normalizedQuery = this.normalize(query);
return products
.map((product) => {
const targetName = this.normalize(product.canonicalName || product.name);
const targetNormalized = this.normalize(product.normalizedName);
if (targetNormalized === normalizedQuery || targetName === normalizedQuery) {
return { product, score: 100 };
}
if (targetName.includes(normalizedQuery) || normalizedQuery.includes(targetName)) {
return { product, score: 70 };
}
const dist = this.levenshtein(normalizedQuery, targetName);
const maxLen = Math.max(normalizedQuery.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);
}
buildIngredientSuggestions(
rawName: string,
alternatives: string[] | undefined,
products: ProductMatchCandidate[],
): IngredientSuggestion[] {
const variants = alternatives && alternatives.length > 1 ? alternatives : [rawName];
const seenIds = new Set<number>();
return variants
.flatMap((variant) => this.scoreProducts(variant, products))
.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,
}));
}
}
@@ -52,6 +52,14 @@ export class RecipesController {
return this.recipeAnalysisService.analyzeRecipeIngredients(id, user.userId);
}
@Post(':id/rematch')
rematchRecipeIngredients(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: { userId: number },
) {
return this.recipeAnalysisService.rematchRecipeIngredients(id, user.userId);
}
@Get(':id')
findOne(
@Param('id', ParseIntPipe) id: number,
+2 -1
View File
@@ -4,10 +4,11 @@ import { AiModule } from '../ai/ai.module';
import { RecipesController } from './recipes.controller';
import { RecipesService } from './recipes.service';
import { RecipeAnalysisService } from './recipe-analysis.service';
import { RecipeMatchingService } from './recipe-matching.service';
@Module({
imports: [PrismaModule, AiModule],
controllers: [RecipesController],
providers: [RecipesService, RecipeAnalysisService],
providers: [RecipesService, RecipeAnalysisService, RecipeMatchingService],
})
export class RecipesModule {}
+8 -70
View File
@@ -9,7 +9,8 @@ import { CreateIngredientDto } from './dto/create-ingredient.dto';
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
import { downloadAndOptimizeImage } from '../common/utils/download-image';
import { parseRecipeMarkdown, ParsedRecipe, ParsedIngredient } from '../common/utils/recipe-parser';
import { normalizeUnit, getUnitType, convertUnit, canConvert } from '../common/utils/units';
import { convertUnit, canConvert } from '../common/utils/units';
import { RecipeMatchingService } from './recipe-matching.service';
const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images';
@@ -28,6 +29,7 @@ export class RecipesService {
constructor(
private readonly prisma: PrismaService,
private readonly aiService: AiService,
private readonly recipeMatchingService: RecipeMatchingService,
) {}
private throwRecipeNotFound(id: number): never {
@@ -721,76 +723,12 @@ Regler:
select: { id: true, name: true, canonicalName: true, normalizedName: true },
});
// Normalisera en sträng för jämförelse (lowercase, trim, ta bort skiljetecken)
const normalize = (s: string) =>
s.toLowerCase().trim().replace(/[^a-zåäö0-9\s]/gi, '').replace(/\s+/g, ' ');
// Enkel Levenshtein-distans
const levenshtein = (a: string, b: string): number => {
const m = a.length;
const n = b.length;
const dp: number[][] = 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: ParsedIngredient) => {
// Kör matchning mot alla alternativ och slå ihop suggestions
const alternatives = ingredient.alternatives?.length > 1
? ingredient.alternatives
: [ingredient.rawName];
const scoreProduct = (query: string) => allProducts
.map((product) => {
const targetName = normalize(product.canonicalName || product.name);
const targetNormalized = normalize(product.normalizedName);
// Exakt träff på normalizedName prioriteras
if (targetNormalized === query || targetName === query) {
return { product, score: 100 };
}
// Delsträng-match
if (targetName.includes(query) || query.includes(targetName)) {
return { product, score: 70 };
}
// Levenshtein-baserad likhet
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);
// Slå ihop suggestions från alla alternativ, deduplicera på productId, ta topp 5
const seenIds = new Set<number>();
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,
}));
const scored = this.recipeMatchingService.buildIngredientSuggestions(
ingredient.rawName,
ingredient.alternatives,
allProducts,
);
return {
rawName: ingredient.rawName,