batchCooking/apps/api/src/lib/recipe-translation.ts
Nicolas 89722ba790 feat(recipes): matching anglais pour les tech steps et les ingrédients
- 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>
2026-08-20 13:10:00 +02:00

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),
};
}