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
+118
View File
@@ -13,10 +13,128 @@ export type CategorySuggestion = {
usedFallback: boolean;
};
export type AiIngredientMatchSuggestion = {
productId: number;
reason?: string;
confidence: 'high' | 'medium' | 'low';
};
export type AiSubstitutionSuggestion = {
productId: number;
reason?: string;
confidence: 'high' | 'medium' | 'low';
};
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
async suggestIngredientMatches(
rawIngredient: string,
candidates: Array<{ id: number; name: string; canonicalName?: string | null }>,
): Promise<AiIngredientMatchSuggestion[]> {
const apiKey = process.env.MISTRAL_API_KEY;
if (!apiKey || candidates.length === 0) return [];
const candidateList = candidates
.map((c) => `[${c.id}] ${c.canonicalName || c.name}`)
.join('\n');
const systemPrompt = `Du matchar en ingrediensrad mot produktkandidater.
Svara ENDAST med JSON: {"matches":[{"productId":123,"reason":"...","confidence":"high|medium|low"}]}
Regler:
1. Välj max 3 kandidater.
2. Om inget passar, returnera tom lista.`;
const userPrompt = `Ingrediens: "${rawIngredient}"\nKandidater:\n${candidateList}`;
try {
const response = await fetch(MISTRAL_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
max_tokens: 300,
temperature: 0.1,
response_format: { type: 'json_object' },
}),
});
if (!response.ok) {
this.logger.warn(`suggestIngredientMatches API-fel: ${response.status}`);
return [];
}
const data = (await response.json()) as { choices: { message: { content: string } }[] };
const raw = data.choices?.[0]?.message?.content ?? '{}';
const parsed = JSON.parse(raw) as { matches?: AiIngredientMatchSuggestion[] };
return Array.isArray(parsed.matches) ? parsed.matches.slice(0, 3) : [];
} catch (err) {
this.logger.warn(`suggestIngredientMatches misslyckades: ${String(err)}`);
return [];
}
}
async suggestSubstitutions(
rawIngredient: string,
availableProducts: Array<{ id: number; name: string; canonicalName?: string | null }>,
): Promise<AiSubstitutionSuggestion[]> {
const apiKey = process.env.MISTRAL_API_KEY;
if (!apiKey || availableProducts.length === 0) return [];
const productList = availableProducts
.map((p) => `[${p.id}] ${p.canonicalName || p.name}`)
.join('\n');
const systemPrompt = `Du föreslår ersättningsvaror för en ingrediens.
Svara ENDAST med JSON: {"substitutions":[{"productId":123,"reason":"...","confidence":"high|medium|low"}]}
Regler:
1. Välj max 3 ersättningar.
2. Om inget passar, returnera tom lista.`;
const userPrompt = `Ingrediens: "${rawIngredient}"\nTillgängliga produkter:\n${productList}`;
try {
const response = await fetch(MISTRAL_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
max_tokens: 300,
temperature: 0.2,
response_format: { type: 'json_object' },
}),
});
if (!response.ok) {
this.logger.warn(`suggestSubstitutions API-fel: ${response.status}`);
return [];
}
const data = (await response.json()) as { choices: { message: { content: string } }[] };
const raw = data.choices?.[0]?.message?.content ?? '{}';
const parsed = JSON.parse(raw) as { substitutions?: AiSubstitutionSuggestion[] };
return Array.isArray(parsed.substitutions) ? parsed.substitutions.slice(0, 3) : [];
} catch (err) {
this.logger.warn(`suggestSubstitutions misslyckades: ${String(err)}`);
return [];
}
}
async suggestCategory(
productName: string,
categories: FlatCategory[],
@@ -159,7 +159,7 @@ export class ReceiptImportService {
const matched = await this.matchProducts(rawItems, userId);
// Steg 3: Regel + AI-kategorisering för alla användare
return this.enrichWithAiCategories(matched);
return this.enrichWithAiCategories(matched, userId);
}
private async parseReceiptViaImporter(file: Express.Multer.File): Promise<ParsedReceiptItem[]> {
@@ -341,7 +341,7 @@ export class ReceiptImportService {
return best?.product;
}
private async enrichWithAiCategories(items: ParsedReceiptItem[]): Promise<ParsedReceiptItem[]> {
private async enrichWithAiCategories(items: ParsedReceiptItem[], userId?: number): Promise<ParsedReceiptItem[]> {
let categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
try {
categories = await this.categoriesService.findFlattened();
@@ -349,6 +349,13 @@ export class ReceiptImportService {
return items; // Om kategoritjänsten är otillgänglig, returnera utan AI-förslag
}
const user = userId
? await this.prisma.user.findUnique({
where: { id: userId },
select: { aiEngineEnabled: true },
})
: null;
const enriched: ParsedReceiptItem[] = [];
for (const item of items) {
if (!item.rawName) {
@@ -424,9 +431,13 @@ export class ReceiptImportService {
// AI används som fallback när varken matchning eller regler satte kategori
if (!nextSuggestion) {
pushTrace('ai invoked');
nextSuggestion = await this.aiService.suggestCategory(item.rawName, categories);
pushTrace(`ai result -> "${nextSuggestion.path}" (${nextSuggestion.confidence})`);
if (user?.aiEngineEnabled) {
pushTrace('ai invoked');
nextSuggestion = await this.aiService.suggestCategory(item.rawName, categories);
pushTrace(`ai result -> "${nextSuggestion.path}" (${nextSuggestion.confidence})`);
} else {
pushTrace('ai skipped, feature disabled');
}
} else {
pushTrace(`ai skipped, current -> "${nextSuggestion.path}"`);
}
+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,
+15
View File
@@ -19,6 +19,11 @@ class SetRecipeSharingDto {
canShareRecipes: boolean;
}
class SetAiEngineEnabledDto {
@IsBoolean()
aiEngineEnabled: boolean;
}
class AdminCreateUserDto {
@IsString()
@MinLength(2)
@@ -128,6 +133,16 @@ export class UsersController {
return { id: updated.id, username: updated.username, canShareRecipes: updated.canShareRecipes };
}
@Roles('admin')
@Patch(':id/ai-engine')
async setAiEngineEnabled(
@Param('id', ParseIntPipe) id: number,
@Body() dto: SetAiEngineEnabledDto,
) {
const updated = await this.usersService.setAiEngineEnabled(id, dto.aiEngineEnabled);
return { id: updated.id, username: updated.username, aiEngineEnabled: updated.aiEngineEnabled };
}
@Roles('admin')
@Post()
async adminCreateUser(
+5
View File
@@ -34,6 +34,7 @@ export class UsersService {
role: true,
isPremium: true,
canShareRecipes: true,
aiEngineEnabled: true,
createdAt: true,
},
orderBy: { username: 'asc' },
@@ -52,6 +53,10 @@ export class UsersService {
return this.prisma.user.update({ where: { id }, data: { canShareRecipes } });
}
setAiEngineEnabled(id: number, aiEngineEnabled: boolean) {
return this.prisma.user.update({ where: { id }, data: { aiEngineEnabled } });
}
async adminCreate(data: { username: string; email: string; password: string; role?: string }) {
const existing = await this.prisma.user.findFirst({
where: { OR: [{ username: data.username }, { email: data.email }] },