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:
parent
b060fc47c3
commit
5b868e4422
2 changed files with 229 additions and 0 deletions
86
apps/api/src/lib/recipe-translation.ts
Normal file
86
apps/api/src/lib/recipe-translation.ts
Normal 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);
|
||||||
|
}
|
||||||
143
apps/api/test/recipe-translation.test.ts
Normal file
143
apps/api/test/recipe-translation.test.ts
Normal 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([[]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue