feat(recipes): traduction des étapes d'une recette importée en tech steps

Ajoute la brique "Traduction en étapes" du pipeline d'import décrit
dans specs/batch-cooking-architecture.md — prend un ParsedRecipe
(sortie de parse() d'un adaptateur, recipe-source-adapter.ts) et
déclare, pour chaque étape, sa séquence de tech steps détectée.

- recipe-translation.ts : TranslatedRecipe/TranslatedRecipeStep
  (ParsedRecipe/ParsedRecipeStep + techStepIds: number[], même forme
  que Step.techSteps/StepTechStep). translateRecipeSteps() est pure
  (prend les mappings en argument, comme matchTechSteps lui-même) ;
  translateRecipe() est le wrapper qui charge le catalogue depuis la
  DB pour une locale donnée — même séparation pur/DB que
  tech-step-matcher.ts.
- Ne touche pas aux ingrédients (résolution vers Ingredient/Unit
  toujours hors scope) ni ne produit une Recipe sauvegardable (pas de
  dietIds/visibility/auteur) — une seule brique du pipeline, pas tout
  le pipeline.
- Documente explicitement la limite actuelle : le catalogue de tech
  steps n'a que des mappings "fr", donc une source anglophone comme
  TheMealDB traduite avec cette locale obtient des séquences vides
  sur toutes ses étapes (vérifié par un test dédié avec du texte
  réel de TheMealDB).

8 nouveaux tests (partie pure + partie DB avec le vrai catalogue
"fr" seedé). 180 tests passent au total. Build et lint propres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-20 11:39:15 +02:00
parent b060fc47c3
commit 5b868e4422
2 changed files with 229 additions and 0 deletions

View file

@ -0,0 +1,86 @@
import type { ParsedRecipe, 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.
*
* Deliberately doesn't touch ingredients resolving free-text ingredient
* lines against our `Ingredient`/`Unit` catalogs is a separate, not-yet-built
* concern (see `ParsedRecipeIngredient`'s doc comment) — and 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` is pure (takes `techStepMappings` as a plain
* argument, same convention as `matchTechSteps` itself) so it's 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 ParsedRecipe} whose `steps` have been translated — everything else (name, ingredients, portions, …) passes through unchanged. */
export interface TranslatedRecipe extends Omit<ParsedRecipe, "steps"> {
steps: TranslatedRecipeStep[];
}
/**
* Declares each of `recipe`'s steps' technique sequence against
* `techStepMappings`, leaving everything else about the recipe untouched.
* 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,
steps: recipe.steps.map((step) => ({
...step,
techStepIds: matchTechSteps(step.description, techStepMappings),
})),
};
}
/**
* Convenience wrapper around {@link translateRecipeSteps} that loads
* `locale`'s mapping 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
* call `loadTechStepMappingRules` once and reuse it across
* `translateRecipeSteps` 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.
*/
export async function translateRecipe(
recipe: ParsedRecipe,
locale: string,
): Promise<TranslatedRecipe> {
const techStepMappings = await loadTechStepMappingRules(locale);
return translateRecipeSteps(recipe, techStepMappings);
}

View file

@ -0,0 +1,143 @@
import { expect } from "chai";
import { prisma } from "../src/db/prisma.js";
import type { ParsedRecipe } from "../src/lib/recipe-source-adapter.js";
import { translateRecipe, translateRecipeSteps } from "../src/lib/recipe-translation.js";
import type { TechStepMappingRule } from "../src/lib/tech-step-matcher.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** A minimal fixture `ParsedRecipe` — only `steps` (built from `descriptions`) matters for most tests here, the rest is filler to prove it survives translation untouched. */
function buildParsedRecipe(descriptions: string[]): ParsedRecipe {
return {
name: "Test Recipe",
description: "A recipe for testing",
picture: "https://example.test/recipe.jpg",
portions: 4,
sourceUrl: "https://example.test/recipes/1",
ingredients: [{ rawText: "1 egg", quantity: null, unit: null, name: "egg" }],
steps: descriptions.map((description, i) => ({
description,
picture: i === 0 ? "https://example.test/step1.jpg" : null,
})),
};
}
describe("recipe-translation", () => {
describe("translateRecipeSteps", () => {
const simmer: TechStepMappingRule = {
techStepId: 1,
expression: "\\bmijot(er|ez|e|ant|é)\\b",
weight: 15,
};
const preheat: TechStepMappingRule = {
techStepId: 2,
expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b",
weight: 20,
};
const melt: TechStepMappingRule = {
techStepId: 3,
expression: "\\bfaire fondre\\b|\\bfaites fondre\\b",
weight: 15,
};
it("declares each step's technique sequence, preserving order", () => {
const recipe = buildParsedRecipe([
"Préchauffer la poêle, puis faire fondre le beurre",
"Servir immédiatement",
"Faire mijoter à feu doux",
]);
const translated = translateRecipeSteps(recipe, [simmer, preheat, melt]);
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[2, 3], [], [1]]);
});
it("leaves description/picture untouched on each step", () => {
const recipe = buildParsedRecipe(["Faire mijoter à feu doux", "Servir"]);
const translated = translateRecipeSteps(recipe, [simmer]);
expect(translated.steps[0]).to.deep.equal({
description: "Faire mijoter à feu doux",
picture: "https://example.test/step1.jpg",
techStepIds: [1],
});
expect(translated.steps[1]).to.deep.equal({
description: "Servir",
picture: null,
techStepIds: [],
});
});
it("passes every other field through unchanged", () => {
const recipe = buildParsedRecipe(["Servir"]);
const translated = translateRecipeSteps(recipe, []);
expect(translated.name).to.equal(recipe.name);
expect(translated.description).to.equal(recipe.description);
expect(translated.picture).to.equal(recipe.picture);
expect(translated.portions).to.equal(recipe.portions);
expect(translated.sourceUrl).to.equal(recipe.sourceUrl);
expect(translated.ingredients).to.deep.equal(recipe.ingredients);
});
it("gives every step an empty sequence when there are no mappings at all", () => {
const recipe = buildParsedRecipe(["Faire mijoter à feu doux", "Préchauffer le four"]);
const translated = translateRecipeSteps(recipe, []);
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[], []]);
});
it("handles a recipe with no steps without error", () => {
const recipe = buildParsedRecipe([]);
const translated = translateRecipeSteps(recipe, [simmer]);
expect(translated.steps).to.deep.equal([]);
});
});
describe("translateRecipe", () => {
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
it("resolves real TechStep ids from the seeded French catalog", async () => {
const simmer = await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } });
const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } });
const recipe = buildParsedRecipe(["Hacher les oignons", "Faire mijoter à feu doux"]);
const translated = await translateRecipe(recipe, "fr");
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([
[chop.id],
[simmer.id],
]);
});
it("finds nothing for English text against the French catalog — the current locale gap", async () => {
// A step lifted verbatim from a real TheMealDB recipe.
const recipe = buildParsedRecipe([
"Bring a large saucepan of salted water to the boil",
"Chop the onions finely",
]);
const translated = await translateRecipe(recipe, "fr");
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[], []]);
});
it("finds nothing for a locale with no mappings, even for text that would otherwise match", async () => {
const recipe = buildParsedRecipe(["Faire mijoter à feu doux"]);
const translated = await translateRecipe(recipe, "en");
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[]]);
});
});
});