feat: add rematch functionality for recipe ingredients and enhance inventory management
Test Suite / test (24.15.0) (push) Has been cancelled
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:
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user