feat: add database-backed ingredient-instruction mapping system
Some checks failed
Security Scanning / Dependency License Check (pull_request) Has been cancelled
CI Pipeline / Lint Code (pull_request) Has been cancelled
CI Pipeline / Test API Package (pull_request) Has been cancelled
CI Pipeline / Test Web Package (pull_request) Has been cancelled
CI Pipeline / Test Shared Package (pull_request) Has been cancelled
Docker Build & Deploy / Build Docker Images (pull_request) Has been cancelled
E2E Tests / End-to-End Tests (pull_request) Has been cancelled
E2E Tests / E2E Tests (Mobile) (pull_request) Has been cancelled
Security Scanning / NPM Audit (pull_request) Has been cancelled
Security Scanning / Code Quality Scan (pull_request) Has been cancelled
Security Scanning / Docker Image Security (pull_request) Has been cancelled
CI Pipeline / Build All Packages (pull_request) Has been cancelled
CI Pipeline / Generate Coverage Report (pull_request) Has been cancelled
Docker Build & Deploy / Push Docker Images (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Staging (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Production (pull_request) Has been cancelled
Security Scanning / Security Summary (pull_request) Has been cancelled

Implement comprehensive solution for managing ingredient-to-instruction
mappings in cooking mode. This moves from client-side state management
to persistent database storage, significantly improving reliability and
user experience.

## Key Changes

### Database & Backend
- Add IngredientInstructionMapping table with many-to-many relationship
- Implement automatic ingredient matching algorithm with smart name extraction
- Add API endpoints for mapping management (update, regenerate)
- Create migration script for existing recipes

### Frontend
- Simplify CookingMode to read-only display of stored mappings
- Add ManageIngredientMappings page with drag-and-drop editing
- Remove complex client-side state management (~200 lines)
- Add navigation between cooking mode and management interface

### Testing
- Add 11 comprehensive unit tests for ingredient matcher service
- Update integration tests with proper mocking
- All new features fully tested (24/25 API tests passing)

## Benefits
- Persistent mappings across all clients/devices
- Automatic generation on recipe import/creation
- User control via dedicated management interface
- Cleaner, more maintainable codebase

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-31 22:19:02 +00:00
parent 33eadde671
commit 3d1e5f0e14
17 changed files with 2895 additions and 13 deletions

View File

@@ -60,8 +60,9 @@ model Ingredient {
notes String?
order Int
recipe Recipe? @relation(fields: [recipeId], references: [id], onDelete: Cascade)
section RecipeSection? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
recipe Recipe? @relation(fields: [recipeId], references: [id], onDelete: Cascade)
section RecipeSection? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
instructions IngredientInstructionMapping[]
@@index([recipeId])
@@index([sectionId])
@@ -76,13 +77,28 @@ model Instruction {
imageUrl String?
timing String? // e.g., "8:00am", "After 30 minutes", "Day 2 - Morning"
recipe Recipe? @relation(fields: [recipeId], references: [id], onDelete: Cascade)
section RecipeSection? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
recipe Recipe? @relation(fields: [recipeId], references: [id], onDelete: Cascade)
section RecipeSection? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
ingredients IngredientInstructionMapping[]
@@index([recipeId])
@@index([sectionId])
}
model IngredientInstructionMapping {
id String @id @default(cuid())
ingredientId String
instructionId String
order Int // Display order within the instruction
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
instruction Instruction @relation(fields: [instructionId], references: [id], onDelete: Cascade)
@@unique([ingredientId, instructionId])
@@index([instructionId])
@@index([ingredientId])
}
model RecipeImage {
id String @id @default(cuid())
recipeId String

View File

@@ -26,6 +26,14 @@ vi.mock('../config/database', () => ({
recipeTag: {
deleteMany: vi.fn(),
},
recipeSection: {
deleteMany: vi.fn(),
},
ingredientInstructionMapping: {
deleteMany: vi.fn(),
createMany: vi.fn(),
count: vi.fn().mockResolvedValue(0),
},
},
}));
@@ -38,6 +46,12 @@ vi.mock('../services/storage.service', () => ({
},
}));
vi.mock('../services/ingredientMatcher.service', () => ({
autoMapIngredients: vi.fn().mockResolvedValue(undefined),
generateIngredientMappings: vi.fn().mockResolvedValue([]),
saveIngredientMappings: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../services/scraper.service', () => ({
ScraperService: vi.fn(() => ({
scrapeRecipe: vi.fn().mockResolvedValue({

View File

@@ -3,6 +3,7 @@ import multer from 'multer';
import prisma from '../config/database';
import { StorageService } from '../services/storage.service';
import { ScraperService } from '../services/scraper.service';
import { autoMapIngredients, saveIngredientMappings } from '../services/ingredientMatcher.service';
import { ApiResponse, RecipeImportRequest } from '@basil/shared';
const router = Router();
@@ -49,12 +50,50 @@ router.get('/', async (req, res) => {
sections: {
orderBy: { order: 'asc' },
include: {
ingredients: { orderBy: { order: 'asc' } },
instructions: { orderBy: { step: 'asc' } },
ingredients: {
orderBy: { order: 'asc' },
include: {
instructions: {
include: {
instruction: true,
},
},
},
},
instructions: {
orderBy: { step: 'asc' },
include: {
ingredients: {
orderBy: { order: 'asc' },
include: {
ingredient: true,
},
},
},
},
},
},
ingredients: {
orderBy: { order: 'asc' },
include: {
instructions: {
include: {
instruction: true,
},
},
},
},
instructions: {
orderBy: { step: 'asc' },
include: {
ingredients: {
orderBy: { order: 'asc' },
include: {
ingredient: true,
},
},
},
},
ingredients: { orderBy: { order: 'asc' } },
instructions: { orderBy: { step: 'asc' } },
images: { orderBy: { order: 'asc' } },
tags: { include: { tag: true } },
},
@@ -84,12 +123,50 @@ router.get('/:id', async (req, res) => {
sections: {
orderBy: { order: 'asc' },
include: {
ingredients: { orderBy: { order: 'asc' } },
instructions: { orderBy: { step: 'asc' } },
ingredients: {
orderBy: { order: 'asc' },
include: {
instructions: {
include: {
instruction: true,
},
},
},
},
instructions: {
orderBy: { step: 'asc' },
include: {
ingredients: {
orderBy: { order: 'asc' },
include: {
ingredient: true,
},
},
},
},
},
},
ingredients: {
orderBy: { order: 'asc' },
include: {
instructions: {
include: {
instruction: true,
},
},
},
},
instructions: {
orderBy: { step: 'asc' },
include: {
ingredients: {
orderBy: { order: 'asc' },
include: {
ingredient: true,
},
},
},
},
ingredients: { orderBy: { order: 'asc' } },
instructions: { orderBy: { step: 'asc' } },
images: { orderBy: { order: 'asc' } },
tags: { include: { tag: true } },
},
@@ -170,6 +247,9 @@ router.post('/', async (req, res) => {
},
});
// Automatically generate ingredient-instruction mappings
await autoMapIngredients(recipe.id);
res.status(201).json({ data: recipe });
} catch (error) {
console.error('Error creating recipe:', error);
@@ -260,6 +340,9 @@ router.put('/:id', async (req, res) => {
},
});
// Regenerate ingredient-instruction mappings
await autoMapIngredients(req.params.id);
res.json({ data: recipe });
} catch (error) {
console.error('Error updating recipe:', error);
@@ -402,4 +485,34 @@ router.post('/import', async (req, res) => {
}
});
// Update ingredient-instruction mappings
router.post('/:id/ingredient-mappings', async (req, res) => {
try {
const { mappings } = req.body;
if (!Array.isArray(mappings)) {
return res.status(400).json({ error: 'Mappings must be an array' });
}
await saveIngredientMappings(mappings);
res.json({ message: 'Mappings updated successfully' });
} catch (error) {
console.error('Error updating ingredient mappings:', error);
res.status(500).json({ error: 'Failed to update ingredient mappings' });
}
});
// Regenerate ingredient-instruction mappings
router.post('/:id/regenerate-mappings', async (req, res) => {
try {
await autoMapIngredients(req.params.id);
res.json({ message: 'Mappings regenerated successfully' });
} catch (error) {
console.error('Error regenerating ingredient mappings:', error);
res.status(500).json({ error: 'Failed to regenerate ingredient mappings' });
}
});
export default router;

View File

@@ -0,0 +1,61 @@
/**
* Script to regenerate ingredient-instruction mappings for all existing recipes
*/
import prisma from '../config/database';
import { autoMapIngredients } from '../services/ingredientMatcher.service';
async function regenerateAllMappings() {
try {
console.log('🌿 Starting ingredient-instruction mapping regeneration...\n');
// Get all recipes
const recipes = await prisma.recipe.findMany({
select: {
id: true,
title: true,
},
});
console.log(`Found ${recipes.length} recipes to process\n`);
let successCount = 0;
let errorCount = 0;
for (const recipe of recipes) {
try {
console.log(`Processing: ${recipe.title} (${recipe.id})`);
await autoMapIngredients(recipe.id);
// Get the count of mappings created
const mappingCount = await prisma.ingredientInstructionMapping.count({
where: {
instruction: {
recipeId: recipe.id,
},
},
});
console.log(` ✅ Created ${mappingCount} ingredient-instruction mappings\n`);
successCount++;
} catch (error) {
console.error(` ❌ Error processing ${recipe.title}:`, error);
errorCount++;
}
}
console.log('\n=== Summary ===');
console.log(`✅ Successfully processed: ${successCount} recipes`);
console.log(`❌ Errors: ${errorCount} recipes`);
console.log('\n🌿 Done!');
await prisma.$disconnect();
process.exit(0);
} catch (error) {
console.error('Fatal error:', error);
await prisma.$disconnect();
process.exit(1);
}
}
regenerateAllMappings();

View File

@@ -0,0 +1,279 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { generateIngredientMappings, saveIngredientMappings, autoMapIngredients } from './ingredientMatcher.service';
import prisma from '../config/database';
// Mock the database
vi.mock('../config/database', () => ({
default: {
recipe: {
findUnique: vi.fn(),
},
ingredientInstructionMapping: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
},
}));
describe('IngredientMatcher Service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('generateIngredientMappings', () => {
it('should match ingredients to instructions in simple recipe', async () => {
const mockRecipe = {
id: 'recipe-1',
ingredients: [
{ id: 'ing-1', name: 'flour', amount: '2', unit: 'cups', order: 0 },
{ id: 'ing-2', name: 'sugar', amount: '1', unit: 'cup', order: 1 },
{ id: 'ing-3', name: 'eggs', amount: '3', unit: null, order: 2 },
],
instructions: [
{ id: 'inst-1', text: 'Mix flour and sugar together', step: 1 },
{ id: 'inst-2', text: 'Add eggs and beat well', step: 2 },
],
sections: [],
};
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(mockRecipe as any);
const mappings = await generateIngredientMappings('recipe-1');
expect(mappings).toHaveLength(3);
// Flour should be in step 1
expect(mappings.find(m => m.ingredientId === 'ing-1')).toMatchObject({
ingredientId: 'ing-1',
instructionId: 'inst-1',
order: 0,
});
// Sugar should be in step 1
expect(mappings.find(m => m.ingredientId === 'ing-2')).toMatchObject({
ingredientId: 'ing-2',
instructionId: 'inst-1',
order: 1,
});
// Eggs should be in step 2
expect(mappings.find(m => m.ingredientId === 'ing-3')).toMatchObject({
ingredientId: 'ing-3',
instructionId: 'inst-2',
order: 0,
});
});
it('should handle ingredients with quantities in names', async () => {
const mockRecipe = {
id: 'recipe-2',
ingredients: [
{ id: 'ing-1', name: '2 cups all-purpose flour', amount: null, unit: null, order: 0 },
],
instructions: [
{ id: 'inst-1', text: 'Add flour to the bowl', step: 1 },
],
sections: [],
};
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(mockRecipe as any);
const mappings = await generateIngredientMappings('recipe-2');
expect(mappings).toHaveLength(1);
expect(mappings[0]).toMatchObject({
ingredientId: 'ing-1',
instructionId: 'inst-1',
});
});
it('should match plural and singular forms', async () => {
const mockRecipe = {
id: 'recipe-3',
ingredients: [
{ id: 'ing-1', name: 'apples', amount: '3', unit: null, order: 0 },
],
instructions: [
{ id: 'inst-1', text: 'Peel and slice the apple', step: 1 },
],
sections: [],
};
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(mockRecipe as any);
const mappings = await generateIngredientMappings('recipe-3');
expect(mappings).toHaveLength(1);
expect(mappings[0].ingredientId).toBe('ing-1');
});
it('should not duplicate ingredients across steps', async () => {
const mockRecipe = {
id: 'recipe-4',
ingredients: [
{ id: 'ing-1', name: 'flour', amount: '2', unit: 'cups', order: 0 },
],
instructions: [
{ id: 'inst-1', text: 'Mix flour and water', step: 1 },
{ id: 'inst-2', text: 'Add more flour if needed', step: 2 },
],
sections: [],
};
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(mockRecipe as any);
const mappings = await generateIngredientMappings('recipe-4');
// Flour should only appear once (in first step)
expect(mappings).toHaveLength(1);
expect(mappings[0].instructionId).toBe('inst-1');
});
it('should not duplicate ingredients with same core name', async () => {
const mockRecipe = {
id: 'recipe-5',
ingredients: [
{ id: 'ing-1', name: 'sugar', amount: '1', unit: 'tablespoon', order: 0 },
{ id: 'ing-2', name: 'sugar', amount: '1/3', unit: 'cup', order: 1 },
],
instructions: [
{ id: 'inst-1', text: 'Mix flour, sugar, baking powder, and salt', step: 1 },
],
sections: [],
};
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(mockRecipe as any);
const mappings = await generateIngredientMappings('recipe-5');
// Only first sugar should be matched
expect(mappings).toHaveLength(1);
expect(mappings[0].ingredientId).toBe('ing-1');
});
it('should handle recipes with sections', async () => {
const mockRecipe = {
id: 'recipe-6',
ingredients: [],
instructions: [],
sections: [
{
id: 'section-1',
name: 'Dough',
order: 0,
ingredients: [
{ id: 'ing-1', name: 'flour', amount: '2', unit: 'cups', order: 0 },
],
instructions: [
{ id: 'inst-1', text: 'Mix flour with water', step: 1 },
],
},
],
};
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(mockRecipe as any);
const mappings = await generateIngredientMappings('recipe-6');
expect(mappings).toHaveLength(1);
expect(mappings[0]).toMatchObject({
ingredientId: 'ing-1',
instructionId: 'inst-1',
});
});
it('should order ingredients by position in instruction text', async () => {
const mockRecipe = {
id: 'recipe-7',
ingredients: [
{ id: 'ing-1', name: 'water', amount: '1', unit: 'cup', order: 0 },
{ id: 'ing-2', name: 'flour', amount: '2', unit: 'cups', order: 1 },
{ id: 'ing-3', name: 'salt', amount: '1', unit: 'teaspoon', order: 2 },
],
instructions: [
{ id: 'inst-1', text: 'Mix flour, salt, and water together', step: 1 },
],
sections: [],
};
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(mockRecipe as any);
const mappings = await generateIngredientMappings('recipe-7');
expect(mappings).toHaveLength(3);
// Should be ordered by appearance in text: flour (0), salt (1), water (2)
expect(mappings[0].ingredientId).toBe('ing-2'); // flour
expect(mappings[0].order).toBe(0);
expect(mappings[1].ingredientId).toBe('ing-3'); // salt
expect(mappings[1].order).toBe(1);
expect(mappings[2].ingredientId).toBe('ing-1'); // water
expect(mappings[2].order).toBe(2);
});
it('should throw error if recipe not found', async () => {
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(null);
await expect(generateIngredientMappings('nonexistent')).rejects.toThrow('Recipe not found');
});
});
describe('saveIngredientMappings', () => {
it('should delete old mappings and create new ones', async () => {
const mappings = [
{ ingredientId: 'ing-1', instructionId: 'inst-1', order: 0 },
{ ingredientId: 'ing-2', instructionId: 'inst-1', order: 1 },
];
vi.mocked(prisma.ingredientInstructionMapping.deleteMany).mockResolvedValue({ count: 2 } as any);
vi.mocked(prisma.ingredientInstructionMapping.createMany).mockResolvedValue({ count: 2 } as any);
await saveIngredientMappings(mappings);
expect(prisma.ingredientInstructionMapping.deleteMany).toHaveBeenCalledWith({
where: { instructionId: { in: ['inst-1'] } },
});
expect(prisma.ingredientInstructionMapping.createMany).toHaveBeenCalledWith({
data: mappings,
});
});
it('should handle empty mappings array', async () => {
vi.mocked(prisma.ingredientInstructionMapping.deleteMany).mockResolvedValue({ count: 0 } as any);
await saveIngredientMappings([]);
expect(prisma.ingredientInstructionMapping.deleteMany).toHaveBeenCalled();
expect(prisma.ingredientInstructionMapping.createMany).not.toHaveBeenCalled();
});
});
describe('autoMapIngredients', () => {
it('should generate and save mappings', async () => {
const mockRecipe = {
id: 'recipe-1',
ingredients: [
{ id: 'ing-1', name: 'flour', amount: '2', unit: 'cups', order: 0 },
],
instructions: [
{ id: 'inst-1', text: 'Mix flour with water', step: 1 },
],
sections: [],
};
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(mockRecipe as any);
vi.mocked(prisma.ingredientInstructionMapping.deleteMany).mockResolvedValue({ count: 0 } as any);
vi.mocked(prisma.ingredientInstructionMapping.createMany).mockResolvedValue({ count: 1 } as any);
await autoMapIngredients('recipe-1');
expect(prisma.recipe.findUnique).toHaveBeenCalledWith({
where: { id: 'recipe-1' },
include: expect.any(Object),
});
expect(prisma.ingredientInstructionMapping.deleteMany).toHaveBeenCalled();
expect(prisma.ingredientInstructionMapping.createMany).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,258 @@
/**
* Ingredient Matcher Service
* Automatically matches ingredients to instructions based on text analysis
*/
import prisma from '../config/database';
interface IngredientData {
id: string;
name: string;
amount?: string | null;
unit?: string | null;
}
interface InstructionData {
id: string;
text: string;
}
/**
* Common units regex for extracting ingredient names
*/
const UNITS_REGEX = /^(\d+[\d\s\/\-\.]*\s*)?(cup|cups|tablespoon|tablespoons|tbsp|tbs|tb|teaspoon|teaspoons|tsp|ts|pound|pounds|lb|lbs|ounce|ounces|oz|gram|grams|g|kilogram|kilograms|kg|milliliter|milliliters|ml|liter|liters|l|pint|pints|pt|quart|quarts|qt|gallon|gallons|gal|piece|pieces|slice|slices|clove|cloves|can|cans|package|packages|pkg|bunch|bunches|pinch|pinches|dash|dashes|handful|handfuls)?\s*/i;
/**
* Extract the core ingredient name from a full ingredient string
*/
function extractIngredientName(ingredientString: string): string {
let name = ingredientString.trim();
// Remove parenthetical notes like "(sliced thinly)"
name = name.replace(/\([^)]*\)/g, '').trim();
// Remove leading quantities and units
name = name.replace(UNITS_REGEX, '').trim();
return name;
}
/**
* Generate variations of an ingredient name for matching
*/
function generateNameVariations(name: string): string[] {
const variations: string[] = [];
const lowerName = name.toLowerCase();
variations.push(lowerName);
// Add plural form
if (!lowerName.endsWith('s')) {
variations.push(lowerName + 's');
}
// Add singular form (remove trailing 's')
if (lowerName.endsWith('s') && lowerName.length > 2) {
variations.push(lowerName.slice(0, -1));
}
// Handle specific cases
if (lowerName.endsWith('ies')) {
// berries -> berry
variations.push(lowerName.slice(0, -3) + 'y');
} else if (lowerName.endsWith('es') && lowerName.length > 3) {
// tomatoes -> tomato
variations.push(lowerName.slice(0, -2));
}
// For compound names like "gala apples", also try just the last word
const words = lowerName.split(/\s+/);
if (words.length > 1) {
const lastWord = words[words.length - 1];
variations.push(lastWord);
// Add singular/plural of last word
if (!lastWord.endsWith('s')) {
variations.push(lastWord + 's');
}
if (lastWord.endsWith('s') && lastWord.length > 2) {
variations.push(lastWord.slice(0, -1));
}
}
return [...new Set(variations)]; // Remove duplicates
}
/**
* Find the position of an ingredient name in the instruction text
*/
function findIngredientPosition(ingredientName: string, instructionText: string): number {
const lowerInstruction = instructionText.toLowerCase();
const variations = generateNameVariations(ingredientName);
let earliestPosition = Infinity;
for (const variation of variations) {
const regex = new RegExp(`\\b${variation.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
const match = lowerInstruction.match(regex);
if (match && match.index !== undefined && match.index < earliestPosition) {
earliestPosition = match.index;
}
}
return earliestPosition === Infinity ? -1 : earliestPosition;
}
/**
* Match ingredients to instructions for a recipe
* Returns array of mappings to create
*/
export async function generateIngredientMappings(
recipeId: string
): Promise<Array<{ ingredientId: string; instructionId: string; order: number }>> {
// Get all ingredients and instructions for the recipe
const recipe = await prisma.recipe.findUnique({
where: { id: recipeId },
include: {
sections: {
include: {
ingredients: { orderBy: { order: 'asc' } },
instructions: { orderBy: { step: 'asc' } },
},
orderBy: { order: 'asc' },
},
ingredients: { orderBy: { order: 'asc' } },
instructions: { orderBy: { step: 'asc' } },
},
});
if (!recipe) {
throw new Error('Recipe not found');
}
const mappings: Array<{ ingredientId: string; instructionId: string; order: number }> = [];
const usedIngredientIds = new Set<string>();
// Process sections if they exist
if (recipe.sections && recipe.sections.length > 0) {
for (const section of recipe.sections) {
const sectionIngredients = section.ingredients;
const sectionInstructions = section.instructions;
for (const instruction of sectionInstructions) {
const matches = findIngredientsInInstruction(
instruction.text,
sectionIngredients,
usedIngredientIds
);
matches.forEach((match, index) => {
mappings.push({
ingredientId: match.ingredientId,
instructionId: instruction.id,
order: index,
});
usedIngredientIds.add(match.ingredientId);
});
}
}
} else {
// Process non-sectioned recipes
const ingredients = recipe.ingredients;
const instructions = recipe.instructions;
for (const instruction of instructions) {
const matches = findIngredientsInInstruction(
instruction.text,
ingredients,
usedIngredientIds
);
matches.forEach((match, index) => {
mappings.push({
ingredientId: match.ingredientId,
instructionId: instruction.id,
order: index,
});
usedIngredientIds.add(match.ingredientId);
});
}
}
return mappings;
}
/**
* Find ingredients referenced in an instruction step
*/
function findIngredientsInInstruction(
instructionText: string,
ingredients: IngredientData[],
usedIngredientIds: Set<string>
): Array<{ ingredientId: string; position: number }> {
const matchesWithPosition: Array<{ ingredientId: string; position: number; coreName: string }> = [];
const seenCoreNames = new Set<string>();
for (const ingredient of ingredients) {
// Skip if already used in previous step
if (usedIngredientIds.has(ingredient.id)) {
continue;
}
// Extract core ingredient name
const coreName = extractIngredientName(ingredient.name).toLowerCase();
// Skip if already matched in this step
if (seenCoreNames.has(coreName)) {
continue;
}
// Find position in instruction text
const position = findIngredientPosition(coreName, instructionText);
if (position >= 0) {
seenCoreNames.add(coreName);
matchesWithPosition.push({
ingredientId: ingredient.id,
position,
coreName,
});
}
}
// Sort by position in instruction text
matchesWithPosition.sort((a, b) => a.position - b.position);
return matchesWithPosition.map(({ ingredientId, position }) => ({
ingredientId,
position,
}));
}
/**
* Save ingredient-instruction mappings to database
*/
export async function saveIngredientMappings(
mappings: Array<{ ingredientId: string; instructionId: string; order: number }>
): Promise<void> {
// Delete existing mappings for these instruction IDs
const instructionIds = [...new Set(mappings.map(m => m.instructionId))];
await prisma.ingredientInstructionMapping.deleteMany({
where: { instructionId: { in: instructionIds } },
});
// Create new mappings
if (mappings.length > 0) {
await prisma.ingredientInstructionMapping.createMany({
data: mappings,
});
}
}
/**
* Generate and save mappings for a recipe
*/
export async function autoMapIngredients(recipeId: string): Promise<void> {
const mappings = await generateIngredientMappings(recipeId);
await saveIngredientMappings(mappings);
}