Merge pull request #39 from kyuno053/feat/recipe-step-translation
feat(recipes): traduction des étapes d'une recette importée en tech steps
This commit is contained in:
commit
b9f148a674
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