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
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:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
61
packages/api/src/scripts/regenerate-mappings.ts
Normal file
61
packages/api/src/scripts/regenerate-mappings.ts
Normal 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();
|
||||
279
packages/api/src/services/ingredientMatcher.service.test.ts
Normal file
279
packages/api/src/services/ingredientMatcher.service.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
258
packages/api/src/services/ingredientMatcher.service.ts
Normal file
258
packages/api/src/services/ingredientMatcher.service.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user