recipes: nutrition calculator with BLS/USDA matching, manual overwrites, and skip

Dual-source nutrition system using BLS (German, primary) and USDA (English, fallback)
with ML embedding matching (multilingual-e5-small / all-MiniLM-L6-v2), hybrid
substring-first search, and position-aware scoring heuristics.

Includes per-recipe and global manual ingredient overwrites, ingredient skip/exclude,
referenced recipe nutrition (base refs + anchor tags), section-name dedup,
amino acid tracking, and reactive client-side calculator with NutritionSummary component.
This commit is contained in:
2026-04-01 13:00:52 +02:00
parent 3cafe8955a
commit 7e1181461e
30 changed files with 722384 additions and 12 deletions

View File

@@ -0,0 +1,25 @@
import mongoose from 'mongoose';
/**
* Global nutrition overwrites — manually map ingredient names to BLS/USDA entries.
* Checked during nutrition generation before embedding search.
* Can also mark ingredients as excluded (skipped).
*/
const NutritionOverwriteSchema = new mongoose.Schema({
// The normalized ingredient name this overwrite matches (German, lowercase)
ingredientNameDe: { type: String, required: true },
// Optional English name for display
ingredientNameEn: { type: String },
// What to map to
source: { type: String, enum: ['bls', 'usda', 'skip'], required: true },
fdcId: { type: Number },
blsCode: { type: String },
nutritionDbName: { type: String },
// Whether this ingredient should be excluded from nutrition calculation
excluded: { type: Boolean, default: false },
}, { timestamps: true });
NutritionOverwriteSchema.index({ ingredientNameDe: 1 }, { unique: true });
delete mongoose.models.NutritionOverwrite;
export const NutritionOverwrite = mongoose.model('NutritionOverwrite', NutritionOverwriteSchema);

View File

@@ -163,6 +163,25 @@ const RecipeSchema = new mongoose.Schema(
}
},
// Nutrition calorie/macro mapping for each ingredient
nutritionMappings: [{
sectionIndex: { type: Number, required: true },
ingredientIndex: { type: Number, required: true },
ingredientName: { type: String },
ingredientNameDe: { type: String },
source: { type: String, enum: ['bls', 'usda', 'manual'] },
fdcId: { type: Number },
blsCode: { type: String },
nutritionDbName: { type: String },
matchConfidence: { type: Number },
matchMethod: { type: String, enum: ['exact', 'embedding', 'manual', 'none'] },
gramsPerUnit: { type: Number },
defaultAmountUsed: { type: Boolean, default: false },
unitConversionSource: { type: String, enum: ['direct', 'density', 'usda_portion', 'estimate', 'manual', 'none'] },
manuallyEdited: { type: Boolean, default: false },
excluded: { type: Boolean, default: false },
}],
// Translation metadata for tracking changes
translationMetadata: {
lastModifiedGerman: {type: Date},
@@ -177,6 +196,6 @@ RecipeSchema.index({ "translations.en.translationStatus": 1 });
import type { RecipeModelType } from '$types/types';
let _recipeModel: mongoose.Model<RecipeModelType>;
try { _recipeModel = mongoose.model<RecipeModelType>("Recipe"); } catch { _recipeModel = mongoose.model<RecipeModelType>("Recipe", RecipeSchema); }
export const Recipe = _recipeModel;
// Delete cached model on HMR so schema changes (e.g. new fields) are picked up
delete mongoose.models.Recipe;
export const Recipe = mongoose.model<RecipeModelType>("Recipe", RecipeSchema);