From 3eadb4db41f539262e71ffeca76e0bbbd77e771e Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 20 Aug 2026 07:58:29 +0200 Subject: [PATCH] =?UTF-8?q?fix(recipes):=20une=20=C3=A9tape=20peut=20porte?= =?UTF-8?q?r=20une=20s=C3=A9quence=20de=20tech=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrige le modèle de données suite à une review sur la PR #34 : "Dans une poêle chaude, faire chauffer une noix de beurre" combine deux techniques (preheat + melt), or Step.techStepId ne pouvait en porter qu'une seule (FK simple nullable). - Step.techStepId (FK simple) remplacé par StepTechStep, une table de jointure ordonnée (stepId, techStepId, order) — @@id([stepId, order]) garantit une séquence propre par étape. - tech-step-matcher.ts : matchTechStep(...) → number|null devient matchTechSteps(...) → number[]. Nouvel algorithme : chaque mapping qui matche devient un candidat avec sa position dans le texte ; on garde le meilleur candidat par technique (poids, puis position), on résout les chevauchements entre techniques différentes par poids décroissant (ex: "cuire au four" ne garde que `bake`, pas `cook` en plus), puis on trie le résultat par ordre d'apparition dans le texte — une séquence qui se lit dans le même ordre que l'instruction. - Ajout de la technique "melt" (faire fondre) au catalogue, pour pouvoir tester le cas concret du commentaire de review de bout en bout (préchauffer + faire fondre). - recipe.service.ts : câble StepTechStep via un create imbriqué à la place du champ scalaire. Tests étendus dans tech-step-matcher.test.ts (séquences non chevauchantes, résolution de chevauchement combinée à une technique distincte, etc.) et recipe.test.ts (nouveau test de bout en bout avec deux techniques dans une même étape). 133 tests passent. Co-Authored-By: Claude Sonnet 5 --- .../migration.sql | 28 ++++++ apps/api/prisma/schema.prisma | 36 +++++-- apps/api/src/db/reference-seed-data.ts | 38 +++++--- apps/api/src/lib/tech-step-matcher.ts | 89 ++++++++++++++---- apps/api/src/modules/recipe/recipe.service.ts | 18 +++- apps/api/test-support/reset-db.ts | 2 +- apps/api/test/recipe.test.ts | 55 +++++++++-- apps/api/test/reference.test.ts | 6 +- apps/api/test/tech-step-matcher.test.ts | 93 ++++++++++++++----- apps/web/src/locales/fr/translation.json | 1 + packages/shared/src/types/recipe.ts | 8 +- packages/shared/src/types/reference.ts | 11 ++- 12 files changed, 299 insertions(+), 86 deletions(-) create mode 100644 apps/api/prisma/migrations/20260820110000_step_tech_step_sequence/migration.sql diff --git a/apps/api/prisma/migrations/20260820110000_step_tech_step_sequence/migration.sql b/apps/api/prisma/migrations/20260820110000_step_tech_step_sequence/migration.sql new file mode 100644 index 0000000..e00115d --- /dev/null +++ b/apps/api/prisma/migrations/20260820110000_step_tech_step_sequence/migration.sql @@ -0,0 +1,28 @@ +-- Replaces `Step.tech_step_id` (single nullable FK — at most one technique +-- per step) with `step_tech_step`, an ordered join table — a step can +-- genuinely involve more than one technique (e.g. "Dans une poêle chaude, +-- faire chauffer une noix de beurre" is both `preheat` and `melt`). Per PR +-- review feedback on the first version of this feature; `tech_step`/`step` +-- have never carried real recipe data yet (this feature isn't released), +-- so no backfill is needed. + +-- DropForeignKey +ALTER TABLE "step" DROP CONSTRAINT "step_tech_step_id_fkey"; + +-- AlterTable +ALTER TABLE "step" DROP COLUMN "tech_step_id"; + +-- CreateTable +CREATE TABLE "step_tech_step" ( + "step_id" INTEGER NOT NULL, + "tech_step_id" INTEGER NOT NULL, + "order" INTEGER NOT NULL, + + CONSTRAINT "step_tech_step_pkey" PRIMARY KEY ("step_id", "order") +); + +-- AddForeignKey +ALTER TABLE "step_tech_step" ADD CONSTRAINT "step_tech_step_step_id_fkey" FOREIGN KEY ("step_id") REFERENCES "step"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "step_tech_step" ADD CONSTRAINT "step_tech_step_tech_step_id_fkey" FOREIGN KEY ("tech_step_id") REFERENCES "tech_step"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index fa8aa69..9eddd26 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -564,7 +564,7 @@ model TechStep { id Int @id @default(autoincrement()) key String @unique - steps Step[] + steps StepTechStep[] mappings TechStepMapping[] @@map("tech_step") @@ -573,9 +573,11 @@ model TechStep { /// Used by `tech-step-matcher.ts` to auto-detect which technique a recipe /// step's description corresponds to (expression = regex pattern tested /// against the description, weight = tie-break score when several -/// mappings match). `locale` (e.g. `"fr"`) lets the same TechStep carry -/// one matching rule set per language — the matcher is always called with -/// a target locale and only considers mappings for that locale. +/// mappings match, or overlap-resolution score when two mappings match the +/// same span of text — see `matchTechSteps`). `locale` (e.g. `"fr"`) lets +/// the same TechStep carry one matching rule set per language — the +/// matcher is always called with a target locale and only considers +/// mappings for that locale. model TechStepMapping { id Int @id @default(autoincrement()) techStepId Int @map("tech_step_id") @@ -598,10 +600,30 @@ model Step { description String picture String? order Int - techStepId Int? @map("tech_step_id") - recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) - techStep TechStep? @relation(fields: [techStepId], references: [id], onDelete: SetNull) + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + techSteps StepTechStep[] @@map("step") } + +/// A single step's *ordered sequence* of detected techniques — one +/// instruction can genuinely involve more than one (e.g. "Dans une poêle +/// chaude, faire chauffer une noix de beurre" is both `preheat` and +/// `melt`), which is why this replaced the original single nullable +/// `Step.techStepId` FK (per PR review feedback on the first version of +/// this feature). `order` is the position within *this step* (0-based, in +/// the order `matchTechSteps` — `tech-step-matcher.ts` — detected the +/// techniques in the description), not a global ordering across different +/// steps of the recipe (that's `Step.order`). +model StepTechStep { + stepId Int @map("step_id") + techStepId Int @map("tech_step_id") + order Int + + step Step @relation(fields: [stepId], references: [id], onDelete: Cascade) + techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade) + + @@id([stepId, order]) + @@map("step_tech_step") +} diff --git a/apps/api/src/db/reference-seed-data.ts b/apps/api/src/db/reference-seed-data.ts index 4340894..11bbaa5 100644 --- a/apps/api/src/db/reference-seed-data.ts +++ b/apps/api/src/db/reference-seed-data.ts @@ -45,20 +45,23 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }> // Cooking-technique catalog (French recipe-step normalization) — a static // list of common instructions, each carrying one or more text-matching -// rules used by `tech-step-matcher.ts` to auto-detect which technique a -// free-text `Step.description` corresponds to. Same "English camelCase -// uid, no French label" authoring as DIETS/UNITS — the label lives in -// apps/web's locales/fr/translation.json under `catalog.techSteps.`. +// rules used by `tech-step-matcher.ts` to auto-detect which technique(s) a +// free-text `Step.description` corresponds to (a step can mention several, +// e.g. "faire chauffer une poêle puis y faire fondre le beurre" is both +// `preheat` and `melt` — see `Step.techSteps`/`StepTechStep` in +// schema.prisma). Same "English camelCase uid, no French label" authoring +// as DIETS/UNITS — the label lives in apps/web's +// locales/fr/translation.json under `catalog.techSteps.`. // `expression` is a regex source matched (case/accent-insensitive, via // `normalizeText`) against the step description; `weight` breaks ties when -// a description matches more than one technique's expression (highest -// weight wins) — see `tech-step-matcher.ts`'s `matchTechStep`. Specific, -// multi-word phrases ("cuire au four", "faire revenir") are weighted -// higher than the generic single-verb forms they overlap with ("cuire", -// "sauter") so the more specific technique wins when both match. `locale` -// lets the same technique carry one matching rule set per language — every -// entry below is `"fr"` for now, the field exists so other languages can -// be added later without a schema change. +// two *different* techniques' expressions match the same span of text +// (highest weight wins) — see `tech-step-matcher.ts`'s `matchTechSteps`. +// Specific, multi-word phrases ("cuire au four", "faire revenir") are +// weighted higher than the generic single-verb forms they overlap with +// ("cuire", "sauter") so the more specific technique wins when both match +// the same words. `locale` lets the same technique carry one matching rule +// set per language — every entry below is `"fr"` for now, the field exists +// so other languages can be added later without a schema change. export const TECH_STEPS: Array<{ uid: string; mappings: Array<{ locale: string; expression: string; weight: number }>; @@ -73,6 +76,17 @@ export const TECH_STEPS: Array<{ uid: "fry", mappings: [{ locale: "fr", expression: "\\bfri(re|t|te|ts|tes|ture)\\b", weight: 15 }], }, + { + uid: "melt", + mappings: [ + { + locale: "fr", + expression: + "\\bfondre\\b|\\bfondu(e|es|s)?\\b|\\bfaire fondre\\b|\\bfaites fondre\\b|\\bfaire chauffer\\b|\\bfaites chauffer\\b", + weight: 15, + }, + ], + }, { uid: "deglaze", mappings: [{ locale: "fr", expression: "\\bd[ée]glac(er|ez|é|ée|age)\\b", weight: 20 }], diff --git a/apps/api/src/lib/tech-step-matcher.ts b/apps/api/src/lib/tech-step-matcher.ts index 5c8ecdf..d1aec2f 100644 --- a/apps/api/src/lib/tech-step-matcher.ts +++ b/apps/api/src/lib/tech-step-matcher.ts @@ -1,21 +1,27 @@ import { prisma } from "../db/prisma.js"; /** - * Auto-detects which cooking technique (`TechStep`) a free-text recipe + * Auto-detects which cooking techniques (`TechStep`) a free-text recipe * step description corresponds to, using the static `TechStepMapping` * catalog (see `reference-seed-data.ts`'s `TECH_STEPS`) — groundwork for a * future batch-cooking optimization algorithm, not surfaced in the recipe * UI yet (see `StepView` in `packages/shared`). * - * `normalizeText`/`matchTechStep` are pure (no DB access) so they can be + * A single instruction can genuinely involve more than one technique (e.g. + * "Dans une poêle chaude, faire chauffer une noix de beurre" is both + * `preheat` and `melt`) — `matchTechSteps` returns the whole *ordered + * sequence* it finds, not a single winner, matching `Step.techSteps` + * (schema.prisma's `StepTechStep`, an ordered join table). + * + * `normalizeText`/`matchTechSteps` are pure (no DB access) so they can be * unit-tested in isolation (see `test/tech-step-matcher.test.ts`). * `loadTechStepMappingRules` is the only DB-touching piece, kept separate * so callers (`recipe.service.ts`) fetch the whole mapping list once per - * request and pass it to `matchTechStep` per step, rather than querying + * request and pass it to `matchTechSteps` per step, rather than querying * once per step. */ -/** One `TechStepMapping` row, trimmed to what {@link matchTechStep} needs. */ +/** One `TechStepMapping` row, trimmed to what {@link matchTechSteps} needs. */ export interface TechStepMappingRule { techStepId: number; /** @@ -40,13 +46,40 @@ export function normalizeText(text: string): string { return text.normalize("NFD").replace(COMBINING_DIACRITICS_PATTERN, "").toLowerCase(); } +/** Where in the (normalized) description one mapping matched, alongside the rule that matched — the raw material {@link matchTechSteps} resolves into a final sequence. */ +interface MatchCandidate extends TechStepMappingRule { + start: number; + end: number; +} + +/** Whether two candidates' matched spans share any character position — the case where two *different* techniques' expressions matched the same words (e.g. generic `cook`'s "cuire" inside specific `bake`'s "cuire au four"), meaning only one of them should survive. */ +function overlaps(a: MatchCandidate, b: MatchCandidate): boolean { + return a.start < b.end && b.start < a.end; +} + /** - * Picks the best-matching `TechStep` for a step `description` among - * `mappings` — every mapping whose (normalized) `expression` regex tests - * true against the (normalized) description is a candidate; the candidate - * with the highest `weight` wins, ties broken by lowest `techStepId` - * (equivalent to "first defined in `TECH_STEPS`", since ids are assigned - * in that array's order on first seed). Returns `null` if nothing matches. + * Detects every technique `description` mentions among `mappings`, as an + * ordered sequence of `techStepId`s — empty if none match. The algorithm: + * + * 1. Test every mapping against the normalized description; each one that + * matches becomes a candidate carrying *where* it matched (so + * overlapping matches can be compared). + * 2. Within a single technique, several of its own mappings might all + * match (different phrasings for the same `techStepId`) — keep only + * that technique's best candidate (highest weight, ties broken by + * earliest match), the same tie-break this function always used for a + * single winner. + * 3. Across *different* techniques, two candidates can still overlap (a + * generic pattern matching inside a more specific one's span, e.g. + * `cook` vs `bake` both matching "cuire au four") — resolve greedily by + * weight: take candidates highest-weight first, accept a candidate only + * if it doesn't overlap one already accepted. This is what keeps + * `bake` and drops the redundant `cook` for that phrase, while letting + * two genuinely distinct, non-overlapping techniques (e.g. `preheat` + * and `melt` in "Dans une poêle chaude, faire chauffer une noix de + * beurre") both survive. + * 4. Sort what's left by where it appears in the text — the sequence + * reads in the same order as the instruction itself. * * Pure — takes `mappings` as a plain argument rather than querying Prisma * itself, so it's testable without a database (see @@ -54,23 +87,43 @@ export function normalizeText(text: string): string { * already be filtered to the locale the caller cares about — this function * has no notion of locale, it just tests the rules it's given. */ -export function matchTechStep(description: string, mappings: TechStepMappingRule[]): number | null { +export function matchTechSteps(description: string, mappings: TechStepMappingRule[]): number[] { const normalizedDescription = normalizeText(description); - let best: TechStepMappingRule | null = null; + const candidates: MatchCandidate[] = []; for (const mapping of mappings) { const pattern = new RegExp(normalizeText(mapping.expression), "i"); - if (!pattern.test(normalizedDescription)) continue; + const match = pattern.exec(normalizedDescription); + if (match === null) continue; + candidates.push({ ...mapping, start: match.index, end: match.index + match[0].length }); + } + + // Step 2: one best candidate per techStepId. + const bestByTechStep = new Map(); + for (const candidate of candidates) { + const current = bestByTechStep.get(candidate.techStepId); if ( - best === null || - mapping.weight > best.weight || - (mapping.weight === best.weight && mapping.techStepId < best.techStepId) + current === undefined || + candidate.weight > current.weight || + (candidate.weight === current.weight && candidate.start < current.start) ) { - best = mapping; + bestByTechStep.set(candidate.techStepId, candidate); } } - return best?.techStepId ?? null; + // Step 3: resolve cross-technique overlaps, highest weight first. + const byWeightDesc = [...bestByTechStep.values()].sort( + (a, b) => b.weight - a.weight || a.techStepId - b.techStepId, + ); + const accepted: MatchCandidate[] = []; + for (const candidate of byWeightDesc) { + if (accepted.some((other) => overlaps(candidate, other))) continue; + accepted.push(candidate); + } + + // Step 4: reading order. + accepted.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId); + return accepted.map((candidate) => candidate.techStepId); } /** diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index e5ba108..af5031f 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -13,7 +13,7 @@ import { } from "@batch-cooking/shared"; import type { Prisma } from "@prisma/client"; import { prisma } from "../../db/prisma.js"; -import { loadTechStepMappingRules, matchTechStep } from "../../lib/tech-step-matcher.js"; +import { loadTechStepMappingRules, matchTechSteps } from "../../lib/tech-step-matcher.js"; // No user-language preference exists anywhere in the app yet (a single // "fr" translation file, no locale field on User/UserProfile) — steps are @@ -343,7 +343,12 @@ export async function createRecipe( description: step.description, picture: step.picture ?? null, order: index, - techStepId: matchTechStep(step.description, techStepMappings), + techSteps: { + create: matchTechSteps(step.description, techStepMappings).map((techStepId, order) => ({ + techStepId, + order, + })), + }, })), }, diets: { create: input.dietIds.map((dietId) => ({ dietId })) }, @@ -402,7 +407,14 @@ export async function updateRecipe( description: step.description, picture: step.picture ?? null, order: index, - techStepId: matchTechStep(step.description, techStepMappings), + techSteps: { + create: matchTechSteps(step.description, techStepMappings).map( + (techStepId, order) => ({ + techStepId, + order, + }), + ), + }, })), }, diets: { create: input.dietIds.map((dietId) => ({ dietId })) }, diff --git a/apps/api/test-support/reset-db.ts b/apps/api/test-support/reset-db.ts index 3d6c793..3efcc0a 100644 --- a/apps/api/test-support/reset-db.ts +++ b/apps/api/test-support/reset-db.ts @@ -12,7 +12,7 @@ export async function resetDatabase() { TRUNCATE TABLE "user_profile_allergy", "user_preference", "allergy", "category", "planning_item", "planning", - "recipe_ingredient", "step", "tech_step_mapping", "tech_step", + "recipe_ingredient", "step_tech_step", "step", "tech_step_mapping", "tech_step", "recipe", "ingredients", "sources", "unit", "user_profiles", "diet", "house" RESTART IDENTITY CASCADE; diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index 684134f..5479eff 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -37,6 +37,16 @@ async function techStepId(key: string): Promise { return techStep.id; } +/** A step's detected technique sequence, in order — mirrors `matchTechSteps`' return shape (`../src/lib/tech-step-matcher.js`) so tests can assert on it directly. */ +async function stepTechStepIds(stepId: number): Promise { + const links = await prisma.stepTechStep.findMany({ + where: { stepId }, + orderBy: { order: "asc" }, + select: { techStepId: true }, + }); + return links.map((link) => link.techStepId); +} + describe("Recipes", () => { const app = createApp(); @@ -239,7 +249,7 @@ describe("Recipes", () => { expect(houseRes.body.id).to.be.a("number"); // house exists, sanity check }); - it("auto-detects a step's technique from its description and persists techStepId", async () => { + it("auto-detects a step's technique from its description and persists it", async () => { const { agent } = await signup(); const tomate = await ingredientId("tomato"); const piece = await unitId("piece"); @@ -254,12 +264,12 @@ describe("Recipes", () => { }); expect(res.status).to.equal(201); - // techStepId isn't in the API response (see StepView) — check via Prisma directly. + // Not in the API response (see StepView) — check via Prisma directly. const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } }); - expect(step.techStepId).to.equal(simmer); + expect(await stepTechStepIds(step.id)).to.deep.equal([simmer]); }); - it("leaves techStepId null when a step's description matches no known technique", async () => { + it("leaves a step's technique sequence empty when its description matches no known technique", async () => { const { agent } = await signup(); const tomate = await ingredientId("tomato"); const piece = await unitId("piece"); @@ -273,10 +283,10 @@ describe("Recipes", () => { }); const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } }); - expect(step.techStepId).to.be.null; + expect(await stepTechStepIds(step.id)).to.deep.equal([]); }); - it("detects each step's technique independently, preserving order", async () => { + it("detects each step's technique(s) independently, preserving order", async () => { const { agent } = await signup(); const tomate = await ingredientId("tomato"); const piece = await unitId("piece"); @@ -300,7 +310,11 @@ describe("Recipes", () => { where: { recipeId: res.body.id }, orderBy: { order: "asc" }, }); - expect(steps.map((s) => s.techStepId)).to.deep.equal([chop, null, simmer]); + expect(await Promise.all(steps.map((s) => stepTechStepIds(s.id)))).to.deep.equal([ + [chop], + [], + [simmer], + ]); }); it("picks the more specific technique end-to-end when a description matches more than one", async () => { @@ -320,7 +334,28 @@ describe("Recipes", () => { expect(res.status).to.equal(201); const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } }); - expect(step.techStepId).to.equal(bake); + expect(await stepTechStepIds(step.id)).to.deep.equal([bake]); + }); + + it("detects a sequence of several distinct techniques within a single step, in reading order", async () => { + const { agent } = await signup(); + const tomate = await ingredientId("tomato"); + const piece = await unitId("piece"); + const preheat = await techStepId("preheat"); + const melt = await techStepId("melt"); + + const res = await agent.post("/recipes").send({ + name: "Poêlée", + portions: 4, + dietIds: [], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], + // The case that motivated the sequence model: one instruction, two techniques. + steps: [{ description: "Préchauffer la poêle, puis faire fondre le beurre" }], + }); + + expect(res.status).to.equal(201); + const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } }); + expect(await stepTechStepIds(step.id)).to.deep.equal([preheat, melt]); }); it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => { @@ -535,7 +570,7 @@ describe("Recipes", () => { expect(res.body.steps).to.have.length(2); }); - it("recomputes techStepId for the replaced steps", async () => { + it("recomputes each replaced step's technique sequence", async () => { const { agent } = await signup(); const tomate = await ingredientId("tomato"); const piece = await unitId("piece"); @@ -558,7 +593,7 @@ describe("Recipes", () => { expect(res.status).to.equal(200); const step = await prisma.step.findFirstOrThrow({ where: { recipeId: created.body.id } }); - expect(step.techStepId).to.equal(mince); + expect(await stepTechStepIds(step.id)).to.deep.equal([mince]); }); it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => { diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index 934ed98..1e5a178 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -103,7 +103,7 @@ describe("Reference data", () => { const res = await request(app).get("/reference/tech-steps"); expect(res.status).to.equal(200); - expect(res.body).to.have.length(25); + expect(res.body).to.have.length(26); expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer"); expect(res.body[0]).to.have.keys(["id", "key"]); }); @@ -121,8 +121,8 @@ describe("Reference data", () => { await seedReferenceData(prisma); const res = await request(app).get("/reference/tech-steps"); - expect(res.body).to.have.length(25); - expect(await prisma.techStepMapping.count()).to.equal(25); + expect(res.body).to.have.length(26); + expect(await prisma.techStepMapping.count()).to.equal(26); }); }); }); diff --git a/apps/api/test/tech-step-matcher.test.ts b/apps/api/test/tech-step-matcher.test.ts index d4ca4fc..57ddcd0 100644 --- a/apps/api/test/tech-step-matcher.test.ts +++ b/apps/api/test/tech-step-matcher.test.ts @@ -3,7 +3,7 @@ import { prisma } from "../src/db/prisma.js"; import { type TechStepMappingRule, loadTechStepMappingRules, - matchTechStep, + matchTechSteps, normalizeText, } from "../src/lib/tech-step-matcher.js"; import { resetDatabase } from "../test-support/reset-db.js"; @@ -27,7 +27,7 @@ describe("tech-step-matcher", () => { }); }); - describe("matchTechStep", () => { + describe("matchTechSteps", () => { const simmer: TechStepMappingRule = { techStepId: 1, expression: "\\bmijot(er|ez|e|ant|é)\\b", @@ -44,41 +44,84 @@ describe("tech-step-matcher", () => { "\\bcuire au four\\b|\\bcuisson au four\\b|\\benfourn(er|ez|é|ée|ées)\\b|\\bau four\\b", weight: 25, }; + const preheat: TechStepMappingRule = { + techStepId: 4, + expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b", + weight: 20, + }; + const melt: TechStepMappingRule = { + techStepId: 5, + expression: "\\bfondre\\b|\\bfaire fondre\\b|\\bfaites fondre\\b", + weight: 15, + }; it("matches an exact expression", () => { - expect(matchTechStep("Faire mijoter à feu doux", [simmer])).to.equal(1); + expect(matchTechSteps("Faire mijoter à feu doux", [simmer])).to.deep.equal([1]); }); it("is case- and accent-insensitive, on both the description and the expression itself", () => { // `simmer`'s own expression source contains a literal "é" — exercises // normalizeText being applied to the expression, not just the description. - expect(matchTechStep("FAIRE MIJOTER", [simmer])).to.equal(1); - expect(matchTechStep("faire mijote", [simmer])).to.equal(1); + expect(matchTechSteps("FAIRE MIJOTER", [simmer])).to.deep.equal([1]); + expect(matchTechSteps("faire mijote", [simmer])).to.deep.equal([1]); }); - it("returns null when nothing matches", () => { - expect(matchTechStep("Servir immédiatement", [simmer, cook, bake])).to.be.null; + it("returns an empty sequence when nothing matches", () => { + expect(matchTechSteps("Servir immédiatement", [simmer, cook, bake])).to.deep.equal([]); }); - it("returns null for an empty mappings list", () => { - expect(matchTechStep("Faire mijoter à feu doux", [])).to.be.null; + it("returns an empty sequence for an empty mappings list", () => { + expect(matchTechSteps("Faire mijoter à feu doux", [])).to.deep.equal([]); }); - it("returns null for an empty description", () => { - expect(matchTechStep("", [simmer, cook, bake])).to.be.null; + it("returns an empty sequence for an empty description", () => { + expect(matchTechSteps("", [simmer, cook, bake])).to.deep.equal([]); }); - it("picks the highest-weight match when several mappings match", () => { - // "Cuire au four" matches both `cook` (weight 10) and `bake` (weight 25). - expect(matchTechStep("Cuire au four pendant 30 minutes", [cook, bake])).to.equal(3); + it("detects several distinct, non-overlapping techniques as an ordered sequence", () => { + // The motivating case: "Dans une poêle chaude, faire chauffer une noix + // de beurre" involves both preheating and melting — a step can name + // more than one technique, in the order they're mentioned. + expect( + matchTechSteps("Préchauffer la poêle, puis faire fondre le beurre", [preheat, melt]), + ).to.deep.equal([4, 5]); + // Order in the output follows order of mention in the text, not + // argument order. + expect( + matchTechSteps("Préchauffer la poêle, puis faire fondre le beurre", [melt, preheat]), + ).to.deep.equal([4, 5]); + }); + + it("reverses the sequence when the techniques are mentioned in the opposite order", () => { + expect( + matchTechSteps("Faire fondre le beurre puis préchauffer le four", [preheat, melt]), + ).to.deep.equal([5, 4]); + }); + + it("keeps only the highest-weight technique when two different techniques' expressions overlap the same words", () => { + // "Cuire au four" matches both `cook` (weight 10) and `bake` (weight + // 25) at essentially the same span — only the more specific `bake` + // should survive, not both. + expect(matchTechSteps("Cuire au four pendant 30 minutes", [cook, bake])).to.deep.equal([3]); // Order-independent. - expect(matchTechStep("Cuire au four pendant 30 minutes", [bake, cook])).to.equal(3); + expect(matchTechSteps("Cuire au four pendant 30 minutes", [bake, cook])).to.deep.equal([3]); }); - it("breaks a weight tie by lowest techStepId", () => { + it("still keeps a non-overlapping technique alongside an overlap-resolved one", () => { + // `bake` wins over `cook` for "cuire au four" (overlap), but `melt` + // matches an entirely different, non-overlapping span and survives. + const result = matchTechSteps("Faire fondre le beurre, puis cuire au four", [ + cook, + bake, + melt, + ]); + expect(result).to.deep.equal([5, 3]); + }); + + it("breaks a same-span weight tie by lowest techStepId", () => { const a: TechStepMappingRule = { techStepId: 5, expression: "\\bmelanger\\b", weight: 10 }; const b: TechStepMappingRule = { techStepId: 2, expression: "\\bmelanger\\b", weight: 10 }; - expect(matchTechStep("Mélanger les ingrédients", [a, b])).to.equal(2); + expect(matchTechSteps("Mélanger les ingrédients", [a, b])).to.deep.equal([2]); }); it("still resolves to one techStep when two of its own mappings both match", () => { @@ -92,17 +135,19 @@ describe("tech-step-matcher", () => { expression: "\\bmijoter à feu doux\\b", weight: 15, }; - expect(matchTechStep("Faire mijoter à feu doux", [wholeWord, withAdverb])).to.equal(7); + expect(matchTechSteps("Faire mijoter à feu doux", [wholeWord, withAdverb])).to.deep.equal([ + 7, + ]); }); it("respects word boundaries — a technique's verb embedded in a longer word doesn't false-positive", () => { // "recuire"/"précuit" contain "cuire"/"cuit" as a substring, but not as // a standalone word — the \b-anchored expression must not match them. - expect(matchTechStep("Faire recuire la sauce", [cook])).to.be.null; - expect(matchTechStep("Un plat précuit", [cook])).to.be.null; + expect(matchTechSteps("Faire recuire la sauce", [cook])).to.deep.equal([]); + expect(matchTechSteps("Un plat précuit", [cook])).to.deep.equal([]); // The standalone forms still match. - expect(matchTechStep("Faire cuire la sauce", [cook])).to.equal(2); - expect(matchTechStep("Le riz est cuit", [cook])).to.equal(2); + expect(matchTechSteps("Faire cuire la sauce", [cook])).to.deep.equal([2]); + expect(matchTechSteps("Le riz est cuit", [cook])).to.deep.equal([2]); }); }); @@ -121,10 +166,10 @@ describe("tech-step-matcher", () => { data: { techStepId: simmer.id, locale: "en", expression: "\\bsimmer\\b", weight: 15 }, }); - // The seeded catalog (25 "fr" mappings) must be untouched by the extra + // The seeded catalog (26 "fr" mappings) must be untouched by the extra // "en" row — same count, and none of them carry the English expression. const frRules = await loadTechStepMappingRules("fr"); - expect(frRules).to.have.length(25); + expect(frRules).to.have.length(26); expect(frRules.map((rule) => rule.expression)).to.not.include("\\bsimmer\\b"); const enRules = await loadTechStepMappingRules("en"); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index abb0cb3..a5b3d24 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -346,6 +346,7 @@ "techSteps": { "cook": "Cuire", "fry": "Frire", + "melt": "Faire fondre", "deglaze": "Déglacer", "simmer": "Mijoter", "boil": "Bouillir", diff --git a/packages/shared/src/types/recipe.ts b/packages/shared/src/types/recipe.ts index 336a2b3..3698ff3 100644 --- a/packages/shared/src/types/recipe.ts +++ b/packages/shared/src/types/recipe.ts @@ -25,9 +25,11 @@ export interface RecipeIngredientView { } /** - * A single preparation step within a recipe, in `order`. `tech_step` is - * deliberately not surfaced here — it's tied to the (not yet built) recipe - * import pipeline, out of scope for the manually-authored catalog. + * A single preparation step within a recipe, in `order`. Its detected + * technique sequence (`Step.techSteps`/`StepTechStep` in schema.prisma, + * auto-computed at save time via `tech-step-matcher.ts`) is deliberately + * not surfaced here — groundwork for a future batch-cooking optimization + * algorithm, not yet consumed by any UI. */ export interface StepView { id: number; diff --git a/packages/shared/src/types/reference.ts b/packages/shared/src/types/reference.ts index 4ae1968..bb47423 100644 --- a/packages/shared/src/types/reference.ts +++ b/packages/shared/src/types/reference.ts @@ -193,12 +193,13 @@ export interface UnitView { * `TECH_STEPS`), same static/non-administrable status as * {@link DietView}/{@link UnitView}. * - * Not currently surfaced in the recipe UI — `Step.techStepId` is computed + * Not currently surfaced in the recipe UI — a step's technique sequence + * (`Step.techSteps`/`StepTechStep` in schema.prisma) is computed * server-side at save time (see `recipe.service.ts`'s `createRecipe`/ - * `updateRecipe`, via `tech-step-matcher.ts`) but deliberately excluded - * from `StepView` (see its doc comment in `types/recipe.ts`). This - * endpoint exists for consistency with the other reference catalogs and - * for future admin/inspection tooling. + * `updateRecipe`, via `tech-step-matcher.ts`'s `matchTechSteps`) but + * deliberately excluded from `StepView` (see its doc comment in + * `types/recipe.ts`). This endpoint exists for consistency with the other + * reference catalogs and for future admin/inspection tooling. * * `key` is a stable English camelCase uid (e.g. `"simmer"`), not a display * label — resolved via `t(\`catalog.techSteps.${key}\`)`, same as