- Ajoute une expression régulière anglaise à chacun des 26 TechStepMapping
du catalogue (locale "en"), en plus du "fr" existant — les recettes en
anglais (TheMealDB, etc.) peuvent désormais matcher leurs étapes.
- Nouveau apps/api/src/lib/ingredient-matcher.ts : moteur de matching pur
(nom d'ingrédient, unité, quantité) contre les catalogues Ingredient/Unit,
à partir de labels anglais écrits à la main (packages/shared/src/data/
catalog-labels-en.ts — 546 INGREDIENT_LABELS_EN + 17 UNIT_LABELS_EN avec
synonymes/abréviations). Tokenise et stem naïvement les deux côtés pour
tolérer pluriels et mots descriptifs superflus ; la correspondance la
plus spécifique (le plus de mots) l'emporte en cas de recoupement.
- extractQuantity() : lit un nombre en tête de texte libre (entier,
décimal, fraction simple ou nombre mixte) pour déduire la quantité et
l'unité quand la source ne les fournit pas séparément.
- Étend recipe-translation.ts : translateRecipe(recipe, locale) résout
aussi ingredientId/unitId/quantity de chaque ligne d'ingrédient — mais
uniquement pour locale "en" (seules langue avec des labels), pour ne pas
interroger la base inutilement ni halluciner un match dans une autre
langue.
- Ajoute cup/ounce/pound au catalogue Unit (toBaseFactor réel), absents
jusqu'ici alors que très fréquents dans les recettes anglaises.
- Vérifié en conditions réelles contre TheMealDB (Teriyaki Chicken
Casserole) : 8/9 ingrédients résolus avec la bonne quantité/unité, le
seul raté ("stir-fry vegetables") étant un mélange sans entrée dédiée au
catalogue — dégradation gracieuse (unitId/ingredientId: null) comme prévu.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
163 lines
7.2 KiB
TypeScript
163 lines
7.2 KiB
TypeScript
import {
|
|
type IngredientMatchEntry,
|
|
type UnitMatchEntry,
|
|
extractQuantity,
|
|
loadIngredientCatalog,
|
|
loadUnitCatalog,
|
|
matchIngredientName,
|
|
matchUnit,
|
|
} from "./ingredient-matcher.js";
|
|
import type {
|
|
ParsedRecipe,
|
|
ParsedRecipeIngredient,
|
|
ParsedRecipeStep,
|
|
} from "./recipe-source-adapter.js";
|
|
import {
|
|
type TechStepMappingRule,
|
|
loadTechStepMappingRules,
|
|
matchTechSteps,
|
|
} from "./tech-step-matcher.js";
|
|
|
|
/**
|
|
* The "Traduction en étapes" stage of the import pipeline described in
|
|
* specs/batch-cooking-architecture.md (Import depuis source → **Traduction
|
|
* en étapes** → Sauvegarde) — takes a source-agnostic {@link ParsedRecipe}
|
|
* (recipe-source-adapter.ts's `parse()` output) and declares each step's
|
|
* technique sequence, the same `techStepIds: number[]` shape
|
|
* `Step.techSteps`/`StepTechStep` (schema.prisma) will eventually persist.
|
|
*
|
|
* Also resolves ingredients — matching each free-text `ParsedRecipeIngredient`
|
|
* line against our `Ingredient`/`Unit` catalogs (`ingredient-matcher.ts`),
|
|
* the same "free source text -> our catalog id" idea as tech-step
|
|
* detection, just for ingredients/units/quantities instead of technique
|
|
* verbs. Like tech-step matching, this doesn't turn the result into a
|
|
* saveable `Recipe` either (no `dietIds`/`visibility`/author, a source
|
|
* can't know those) — this is one step of the pipeline, not the whole
|
|
* thing.
|
|
*
|
|
* `translateRecipeSteps`/`translateRecipeIngredients` are pure (take their
|
|
* matching data as plain arguments, same convention as `matchTechSteps`/
|
|
* `matchIngredientName` themselves) so they're unit-testable without a
|
|
* database; `translateRecipe` is the DB-backed convenience wrapper a caller
|
|
* reaches for in practice, mirroring `tech-step-matcher.ts`'s own
|
|
* pure/DB-touching split.
|
|
*/
|
|
|
|
/** A {@link ParsedRecipeStep}, after tech-step detection — declares its technique sequence alongside the description/picture it already had. */
|
|
export interface TranslatedRecipeStep extends ParsedRecipeStep {
|
|
/** Ordered sequence of detected `TechStep` ids (see `matchTechSteps`) — empty if this step doesn't mention any known technique. */
|
|
techStepIds: number[];
|
|
}
|
|
|
|
/** A {@link ParsedRecipeIngredient}, after ingredient/unit matching — `quantity` is filled in from `rawText` when the source itself left it `null` (see `extractQuantity`); `ingredientId`/`unitId` are `null` when nothing in the catalog matched. */
|
|
export interface TranslatedRecipeIngredient extends ParsedRecipeIngredient {
|
|
ingredientId: number | null;
|
|
unitId: number | null;
|
|
}
|
|
|
|
/** A {@link ParsedRecipe} whose `steps`/`ingredients` have been translated — everything else (name, portions, …) passes through unchanged. */
|
|
export interface TranslatedRecipe extends Omit<ParsedRecipe, "steps" | "ingredients"> {
|
|
steps: TranslatedRecipeStep[];
|
|
ingredients: TranslatedRecipeIngredient[];
|
|
}
|
|
|
|
/**
|
|
* Declares each of `recipe`'s steps' technique sequence against
|
|
* `techStepMappings`, leaving everything else about the recipe untouched —
|
|
* including ingredients, which are only stubbed to `TranslatedRecipeIngredient`'s
|
|
* shape here (`ingredientId`/`unitId: null`, `quantity`/`unit` untouched);
|
|
* actually resolving them is {@link translateRecipeIngredients}'s job, kept
|
|
* separate the same way tech-step and ingredient matching are two
|
|
* independent concerns everywhere else in this module. Pure — testable with
|
|
* a hand-built mapping list, no database involved (see `translateRecipe`
|
|
* for the DB-backed loader). `techStepMappings` should already be filtered
|
|
* to the locale the caller cares about, same requirement `matchTechSteps`
|
|
* itself has.
|
|
*/
|
|
export function translateRecipeSteps(
|
|
recipe: ParsedRecipe,
|
|
techStepMappings: TechStepMappingRule[],
|
|
): TranslatedRecipe {
|
|
return {
|
|
...recipe,
|
|
ingredients: recipe.ingredients.map((ingredient) => ({
|
|
...ingredient,
|
|
ingredientId: null,
|
|
unitId: null,
|
|
})),
|
|
steps: recipe.steps.map((step) => ({
|
|
...step,
|
|
techStepIds: matchTechSteps(step.description, techStepMappings),
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
|
|
* against `ingredientCatalog`/`unitCatalog` (see `ingredient-matcher.ts`).
|
|
* Pure — testable with hand-built catalogs, no database involved (see
|
|
* `translateRecipe` for the DB-backed loader). `quantity` falls back to
|
|
* `extractQuantity(rawText)` only when the source itself left it `null`;
|
|
* same for `unit` falling back to `extractQuantity`'s `remainder` before
|
|
* being matched against `unitCatalog` — a source that already states a
|
|
* clean unit/quantity is trusted over re-deriving it from `rawText`.
|
|
*/
|
|
export function translateRecipeIngredients(
|
|
ingredients: ParsedRecipeIngredient[],
|
|
ingredientCatalog: IngredientMatchEntry[],
|
|
unitCatalog: UnitMatchEntry[],
|
|
): TranslatedRecipeIngredient[] {
|
|
return ingredients.map((ingredient) => {
|
|
const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog);
|
|
const extracted = extractQuantity(ingredient.rawText);
|
|
const quantity = ingredient.quantity ?? extracted.quantity;
|
|
const unitText = ingredient.unit ?? extracted.remainder;
|
|
const unitId = matchUnit(unitText, unitCatalog);
|
|
return { ...ingredient, quantity, ingredientId, unitId };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Convenience wrapper around {@link translateRecipeSteps}/
|
|
* {@link translateRecipeIngredients} that loads every catalog itself —
|
|
* what a caller reaches for when translating a single recipe on its own
|
|
* (e.g. the eventual "import this one recipe" endpoint). A caller
|
|
* translating many recipes at once should load the catalogs once and reuse
|
|
* them across calls instead, the same "don't requery per item" reasoning
|
|
* `recipe.service.ts`'s `createRecipe`/`updateRecipe` already follow for
|
|
* manually-authored recipes.
|
|
*
|
|
* No user- or recipe-level language preference exists anywhere in the app
|
|
* yet (see `tech-step-matcher.ts`'s `loadTechStepMappingRules`) — callers
|
|
* pass a locale explicitly rather than this module guessing one. Note that
|
|
* an English-language source (e.g. TheMealDB) translated against `"fr"`
|
|
* mappings will currently get an empty `techStepIds` sequence on every
|
|
* step — matching-language mappings for that source's language don't exist
|
|
* yet, this stage doesn't invent them.
|
|
*
|
|
* Ingredient/unit matching only has English data today
|
|
* (`INGREDIENT_LABELS_EN`/`UNIT_LABELS_EN`, `packages/shared`) — for any
|
|
* `locale` other than `"en"` this skips `loadIngredientCatalog`/
|
|
* `loadUnitCatalog` entirely and leaves every ingredient's `ingredientId`/
|
|
* `unitId` at the neutral `null` `translateRecipeSteps` already stubs in,
|
|
* the same "no matching-language data" degradation tech-step matching
|
|
* already has for a locale with no mappings.
|
|
*/
|
|
export async function translateRecipe(
|
|
recipe: ParsedRecipe,
|
|
locale: string,
|
|
): Promise<TranslatedRecipe> {
|
|
const techStepMappings = await loadTechStepMappingRules(locale);
|
|
const translated = translateRecipeSteps(recipe, techStepMappings);
|
|
|
|
if (locale !== "en") return translated;
|
|
|
|
const [ingredientCatalog, unitCatalog] = await Promise.all([
|
|
loadIngredientCatalog(),
|
|
loadUnitCatalog(),
|
|
]);
|
|
return {
|
|
...translated,
|
|
ingredients: translateRecipeIngredients(recipe.ingredients, ingredientCatalog, unitCatalog),
|
|
};
|
|
}
|