diff --git a/apps/api/prisma/migrations/20260819210000_tech_step_catalog_key/migration.sql b/apps/api/prisma/migrations/20260819210000_tech_step_catalog_key/migration.sql new file mode 100644 index 0000000..28009bd --- /dev/null +++ b/apps/api/prisma/migrations/20260819210000_tech_step_catalog_key/migration.sql @@ -0,0 +1,18 @@ +-- Adds `TechStep.key` (`key String @unique`) and `TechStepMapping.locale` — +-- same "stable English camelCase uid, French label lives only in apps/web's +-- locales/fr/translation.json" convention as Diet/Category/Unit. `TechStep` +-- was created in the initial migration with no way to identify a row other +-- than its numeric id; this closes that gap so reference-seed-data.ts can +-- upsert it by key like every other reference catalog. `locale` lets the +-- same TechStep carry one matching rule set per language. Neither table has +-- ever been seeded (no rows exist pre-launch), so plain NOT NULL columns +-- with no backfill are safe. + +-- AlterTable +ALTER TABLE "tech_step" ADD COLUMN "key" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "tech_step_key_key" ON "tech_step"("key"); + +-- AlterTable +ALTER TABLE "tech_step_mapping" ADD COLUMN "locale" TEXT NOT NULL; 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 23bfef3..9eddd26 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -556,20 +556,32 @@ model RecipeIngredient { @@map("recipe_ingredient") } +/// `key` is `@unique` — same convention as `Diet`/`Unit`: a stable English +/// camelCase uid (e.g. `"simmer"`), not the display label — the French +/// label lives in `apps/web`'s `locales/fr/translation.json` under +/// `catalog.techSteps.` (see `reference-seed-data.ts`'s `TECH_STEPS`). model TechStep { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) + key String @unique - steps Step[] + steps StepTechStep[] mappings TechStepMapping[] @@map("tech_step") } -/// Used by the recipe-import pipeline to auto-detect which technique a raw -/// instruction step corresponds to (expression = text pattern, weight = match score). +/// 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, 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") + locale String expression String weight Int @@ -588,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 adb2998..11bbaa5 100644 --- a/apps/api/src/db/reference-seed-data.ts +++ b/apps/api/src/db/reference-seed-data.ts @@ -43,6 +43,161 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }> { uid: "sprig", type: "COUNT", toBaseFactor: 1 }, ]; +// 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(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 +// 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 }>; +}> = [ + { + uid: "cook", + mappings: [ + { locale: "fr", expression: "\\bcui(re|sez|sant|sson)\\b|\\bcuit(e|es|s)?\\b", weight: 10 }, + ], + }, + { + 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 }], + }, + { + uid: "simmer", + mappings: [{ locale: "fr", expression: "\\bmijot(er|ez|e|ant|é)\\b", weight: 15 }], + }, + { + uid: "boil", + mappings: [ + { locale: "fr", expression: "\\bbouill(ir|ant|ie|ies)\\b|\\b[ée]bullition\\b", weight: 12 }, + ], + }, + { + uid: "roast", + mappings: [{ locale: "fr", expression: "\\br[ôo]tir\\b|\\br[ôo]ti(e|es|s)?\\b", weight: 15 }], + }, + { + uid: "grill", + mappings: [{ locale: "fr", expression: "\\bgrill(er|ez|é|ée|ées|ade)\\b", weight: 15 }], + }, + { + uid: "panFry", + mappings: [{ locale: "fr", expression: "\\bsaut(er|ez|é|ée|ées|ant)\\b", weight: 12 }], + }, + { + uid: "blanch", + mappings: [{ locale: "fr", expression: "\\bblanch(ir|issez|i|ie|ies|iment)\\b", weight: 18 }], + }, + { + uid: "marinate", + mappings: [{ locale: "fr", expression: "\\bmarin(er|ez|é|ée|ées|ade)\\b", weight: 18 }], + }, + { + uid: "chop", + mappings: [{ locale: "fr", expression: "\\bhach(er|ez|é|ée|ées|is)\\b", weight: 15 }], + }, + { + uid: "peel", + mappings: [{ locale: "fr", expression: "\\b[ée]pluch(er|ez|é|ée|ées|age)\\b", weight: 15 }], + }, + { + uid: "mince", + mappings: [{ locale: "fr", expression: "\\b[ée]minc(er|ez|é|ée|ées)\\b", weight: 18 }], + }, + { + uid: "mix", + mappings: [{ locale: "fr", expression: "\\bm[ée]lang(er|ez|é|ée|ées|e|es)\\b", weight: 10 }], + }, + { + uid: "whisk", + mappings: [ + { locale: "fr", expression: "\\bfouett(er|ez|é|ée|ées)\\b|\\bau fouet\\b", weight: 15 }, + ], + }, + { + uid: "foldIn", + mappings: [{ locale: "fr", expression: "\\bincorpor(er|ez|é|ée|ées|ant)\\b", weight: 15 }], + }, + { + uid: "setAside", + mappings: [{ locale: "fr", expression: "\\br[ée]serv(er|ez|é|ée|ées)\\b", weight: 15 }], + }, + { + uid: "season", + mappings: [{ locale: "fr", expression: "\\bassaisonn(er|ez|é|ée|ées|ement)\\b", weight: 15 }], + }, + { + uid: "drain", + mappings: [{ locale: "fr", expression: "\\b[ée]goutt(er|ez|é|ée|ées)\\b", weight: 15 }], + }, + { + uid: "brown", + mappings: [ + { + locale: "fr", + expression: + "\\bfaire revenir\\b|\\bfaites revenir\\b|\\bfais revenir\\b|\\bfaire dorer\\b|\\bfaites dorer\\b", + weight: 25, + }, + ], + }, + { + uid: "rest", + mappings: [ + { locale: "fr", expression: "\\blaiss(er|ez|e) reposer\\b|\\breposer\\b", weight: 20 }, + ], + }, + { + uid: "preheat", + mappings: [{ locale: "fr", expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b", weight: 20 }], + }, + { + uid: "bake", + mappings: [ + { + locale: "fr", + expression: + "\\bcuire au four\\b|\\bcuisson au four\\b|\\benfourn(er|ez|é|ée|ées)\\b|\\bau four\\b", + weight: 25, + }, + ], + }, + { uid: "plate", mappings: [{ locale: "fr", expression: "\\bdress(er|ez|age)\\b", weight: 15 }] }, + { + uid: "coat", + mappings: [{ locale: "fr", expression: "\\bnapp(er|ez|é|ée|ées|age)\\b", weight: 15 }], + }, +]; + // The 14 allergens EU Regulation 1169/2011 (Annex II) requires food // businesses to declare — a standard, defensible reference list rather than // an invented one. Split into ALLERGY (classic IgE-mediated immune @@ -1125,6 +1280,39 @@ export async function seedReferenceData(prisma: PrismaClient): Promise { }); } + // TechStep: upsert by key (same idempotent-seed reasoning as everything + // above), then fully replace its mappings on every reseed. Mappings carry + // no natural per-row identity to upsert against, and expressions/weights + // are expected to be tuned over time — a straight "delete all, recreate + // from source" keeps the table an exact mirror of `TECH_STEPS` rather + // than accumulating stale/duplicate rows from earlier edits. Nothing else + // references `TechStepMapping.id` (`Step` only points at `TechStep`, not + // at a specific mapping), so this replace is safe. + for (const { uid: key } of TECH_STEPS) { + await prisma.techStep.upsert({ where: { key }, update: {}, create: { key } }); + } + const techSteps = await prisma.techStep.findMany({ + where: { key: { in: TECH_STEPS.map((t) => t.uid) } }, + }); + const techStepIdByKey = new Map(techSteps.map((t) => [t.key, t.id])); + + await prisma.techStepMapping.deleteMany({ + where: { techStepId: { in: [...techStepIdByKey.values()] } }, + }); + const techStepMappingRows = TECH_STEPS.flatMap(({ uid, mappings }) => { + const techStepId = techStepIdByKey.get(uid); + if (techStepId === undefined) return []; + return mappings.map(({ locale, expression, weight }) => ({ + techStepId, + locale, + expression, + weight, + })); + }); + if (techStepMappingRows.length > 0) { + await prisma.techStepMapping.createMany({ data: techStepMappingRows }); + } + // `Allergy` itself carries no `key` — it's the selectable instance of a // keyed `Category` (see schema.prisma) — so seeding an allergen means one // Category (upserted by key) plus exactly one Allergy row under it, diff --git a/apps/api/src/lib/tech-step-matcher.ts b/apps/api/src/lib/tech-step-matcher.ts new file mode 100644 index 0000000..d1aec2f --- /dev/null +++ b/apps/api/src/lib/tech-step-matcher.ts @@ -0,0 +1,146 @@ +import { prisma } from "../db/prisma.js"; + +/** + * 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`). + * + * 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 `matchTechSteps` per step, rather than querying + * once per step. + */ + +/** One `TechStepMapping` row, trimmed to what {@link matchTechSteps} needs. */ +export interface TechStepMappingRule { + techStepId: number; + /** + * Regex source, matched against the normalized description (see + * {@link normalizeText}) — may itself contain accented characters, + * normalized the same way before compiling. + */ + expression: string; + weight: number; +} + +/** + * Lowercases and strips diacritics (NFD decomposition + removal of + * combining marks, e.g. "Déglacer" -> "deglacer") — recipe step text and + * mapping expressions are both run through this before matching, so + * expressions can be authored with natural French accents in + * `reference-seed-data.ts` while matching stays accent/case-insensitive. + */ +const COMBINING_DIACRITICS_PATTERN = /\p{Diacritic}/gu; + +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; +} + +/** + * 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 + * `loadTechStepMappingRules` for the DB-backed loader). `mappings` should + * 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 matchTechSteps(description: string, mappings: TechStepMappingRule[]): number[] { + const normalizedDescription = normalizeText(description); + + const candidates: MatchCandidate[] = []; + for (const mapping of mappings) { + const pattern = new RegExp(normalizeText(mapping.expression), "i"); + 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 ( + current === undefined || + candidate.weight > current.weight || + (candidate.weight === current.weight && candidate.start < current.start) + ) { + bestByTechStep.set(candidate.techStepId, candidate); + } + } + + // 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); +} + +/** + * Loads every `TechStepMapping` row for `locale` as + * {@link TechStepMappingRule}s — meant to be fetched once per request by + * `recipe.service.ts`'s `createRecipe`/`updateRecipe` and reused across + * every step of the recipe being saved, not re-queried per step. + * + * No user-language preference exists anywhere in the app yet (a single + * `"fr"` translation file, no locale field on `User`/`UserProfile`) — + * callers pass a hardcoded locale for now; this parameter exists so that + * plugging in a real user preference later doesn't require touching this + * module. + */ +export async function loadTechStepMappingRules(locale: string): Promise { + return prisma.techStepMapping.findMany({ + where: { locale }, + select: { techStepId: true, expression: true, weight: true }, + }); +} diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index 0dfe196..af5031f 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -13,6 +13,14 @@ import { } from "@batch-cooking/shared"; import type { Prisma } from "@prisma/client"; import { prisma } from "../../db/prisma.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 +// matched against this hardcoded locale for now. See +// `tech-step-matcher.ts`'s `loadTechStepMappingRules` for why the locale is +// a parameter rather than baked into that module. +const DEFAULT_TECH_STEP_LOCALE = "fr"; /** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */ function recipeInclude(viewerId: number) { @@ -312,6 +320,7 @@ export async function createRecipe( await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId)); await assertUnitsExist(input.ingredients.map((i) => i.unitId)); await assertDietsExist(input.dietIds); + const techStepMappings = await loadTechStepMappingRules(DEFAULT_TECH_STEP_LOCALE); const created = await prisma.recipe.create({ data: { @@ -334,6 +343,12 @@ export async function createRecipe( description: step.description, picture: step.picture ?? null, order: index, + techSteps: { + create: matchTechSteps(step.description, techStepMappings).map((techStepId, order) => ({ + techStepId, + order, + })), + }, })), }, diets: { create: input.dietIds.map((dietId) => ({ dietId })) }, @@ -366,6 +381,7 @@ export async function updateRecipe( await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId)); await assertUnitsExist(input.ingredients.map((i) => i.unitId)); await assertDietsExist(input.dietIds); + const techStepMappings = await loadTechStepMappingRules(DEFAULT_TECH_STEP_LOCALE); await prisma.$transaction([ prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }), @@ -391,6 +407,14 @@ export async function updateRecipe( description: step.description, picture: step.picture ?? null, order: index, + techSteps: { + create: matchTechSteps(step.description, techStepMappings).map( + (techStepId, order) => ({ + techStepId, + order, + }), + ), + }, })), }, diets: { create: input.dietIds.map((dietId) => ({ dietId })) }, diff --git a/apps/api/src/modules/reference/reference.routes.ts b/apps/api/src/modules/reference/reference.routes.ts index 46f3fe3..84948e3 100644 --- a/apps/api/src/modules/reference/reference.routes.ts +++ b/apps/api/src/modules/reference/reference.routes.ts @@ -1,6 +1,12 @@ import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { Router } from "express"; -import { getAllergies, getDiets, getIngredients, getUnits } from "./reference.service.js"; +import { + getAllergies, + getDiets, + getIngredients, + getTechSteps, + getUnits, +} from "./reference.service.js"; /** * Router mounted at `/reference` in app.ts. Every route is deliberately @@ -40,3 +46,10 @@ referenceRouter.get( res.status(200).json(await getUnits()); }), ); + +referenceRouter.get( + "/tech-steps", + wrapAsyncHandler(async (_req, res) => { + res.status(200).json(await getTechSteps()); + }), +); diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts index 3e212dd..78dadc9 100644 --- a/apps/api/src/modules/reference/reference.service.ts +++ b/apps/api/src/modules/reference/reference.service.ts @@ -1,4 +1,10 @@ -import type { AllergyView, DietView, IngredientView, UnitView } from "@batch-cooking/shared"; +import type { + AllergyView, + DietView, + IngredientView, + TechStepView, + UnitView, +} from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; /** @@ -49,6 +55,15 @@ export async function getUnits(): Promise { })); } +/** + * All reference cooking techniques, ordered by key (see {@link getDiets} + * for why) — small, static list (see `reference-seed-data.ts`'s + * `TECH_STEPS`). Not consumed by the recipe UI yet — see {@link TechStepView}. + */ +export async function getTechSteps(): Promise { + return prisma.techStep.findMany({ orderBy: { key: "asc" } }); +} + /** * All reference ingredients, ordered by key (see {@link getDiets} for why), * each resolved to its allergens (see `IngredientAllergy` in schema.prisma) 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 3528c10..5479eff 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -31,6 +31,22 @@ async function unitId(key: string): Promise { return unit.id; } +/** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as {@link ingredientId}. */ +async function techStepId(key: string): Promise { + const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } }); + 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(); @@ -233,6 +249,115 @@ 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 it", async () => { + const { agent } = await signup(); + const tomate = await ingredientId("tomato"); + const piece = await unitId("piece"); + const simmer = await techStepId("simmer"); + + const res = await agent.post("/recipes").send({ + name: "Ragoût", + portions: 4, + dietIds: [], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], + steps: [{ description: "Faire mijoter à feu doux pendant 30 minutes" }], + }); + + expect(res.status).to.equal(201); + // Not in the API response (see StepView) — check via Prisma directly. + const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } }); + expect(await stepTechStepIds(step.id)).to.deep.equal([simmer]); + }); + + 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"); + + const res = await agent.post("/recipes").send({ + name: "Test", + portions: 4, + dietIds: [], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], + steps: [{ description: "Servir immédiatement" }], + }); + + const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } }); + expect(await stepTechStepIds(step.id)).to.deep.equal([]); + }); + + 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"); + const simmer = await techStepId("simmer"); + const chop = await techStepId("chop"); + + const res = await agent.post("/recipes").send({ + name: "Ragoût", + portions: 4, + dietIds: [], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], + steps: [ + { description: "Hacher les oignons" }, + { description: "Servir immédiatement" }, + { description: "Faire mijoter à feu doux" }, + ], + }); + + expect(res.status).to.equal(201); + const steps = await prisma.step.findMany({ + where: { recipeId: res.body.id }, + orderBy: { order: "asc" }, + }); + 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 () => { + const { agent } = await signup(); + const tomate = await ingredientId("tomato"); + const piece = await unitId("piece"); + const bake = await techStepId("bake"); + + const res = await agent.post("/recipes").send({ + name: "Gratin", + portions: 4, + dietIds: [], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], + // Matches both `cook` (weight 10) and `bake` (weight 25, "au four"). + steps: [{ description: "Cuire au four pendant 30 minutes" }], + }); + + 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([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 () => { const { agent } = await signup(); const piece = await unitId("piece"); @@ -445,6 +570,32 @@ describe("Recipes", () => { expect(res.body.steps).to.have.length(2); }); + it("recomputes each replaced step's technique sequence", async () => { + const { agent } = await signup(); + const tomate = await ingredientId("tomato"); + const piece = await unitId("piece"); + const mince = await techStepId("mince"); + const created = await agent.post("/recipes").send({ + name: "Salade", + portions: 4, + dietIds: [], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], + steps: [{ description: "Servir immédiatement" }], + }); + + const res = await agent.patch(`/recipes/${created.body.id}`).send({ + name: "Salade", + portions: 4, + dietIds: [], + ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }], + steps: [{ description: "Émincer les tomates" }], + }); + + expect(res.status).to.equal(200); + const step = await prisma.step.findFirstOrThrow({ where: { recipeId: created.body.id } }); + expect(await stepTechStepIds(step.id)).to.deep.equal([mince]); + }); + it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => { const { agent } = await signup(); const tomate = await ingredientId("tomato"); diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index dd8bb46..1e5a178 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -2,6 +2,7 @@ import { expect } from "chai"; import request from "supertest"; import { createApp } from "../src/app.js"; import { prisma } from "../src/db/prisma.js"; +import { seedReferenceData } from "../src/db/reference-seed-data.js"; import { resetDatabase } from "../test-support/reset-db.js"; describe("Reference data", () => { @@ -96,4 +97,32 @@ describe("Reference data", () => { expect(byKey("pinch")).to.include({ type: "COUNT", toBaseFactor: 1 }); }); }); + + describe("GET /reference/tech-steps", () => { + it("returns the seeded techniques, no session required", async () => { + const res = await request(app).get("/reference/tech-steps"); + + expect(res.status).to.equal(200); + 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"]); + }); + + it("orders techniques alphabetically by key", async () => { + const res = await request(app).get("/reference/tech-steps"); + + const keys = res.body.map((t: { key: string }) => t.key); + expect(keys).to.deep.equal([...keys].sort()); + }); + + it("reseeding is idempotent — no duplicate techniques or mappings", async () => { + // resetDatabase already seeded once in beforeEach; seed a second time + // on top of that without truncating, the way a redeploy would. + await seedReferenceData(prisma); + + const res = await request(app).get("/reference/tech-steps"); + 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 new file mode 100644 index 0000000..57ddcd0 --- /dev/null +++ b/apps/api/test/tech-step-matcher.test.ts @@ -0,0 +1,185 @@ +import { expect } from "chai"; +import { prisma } from "../src/db/prisma.js"; +import { + type TechStepMappingRule, + loadTechStepMappingRules, + matchTechSteps, + normalizeText, +} from "../src/lib/tech-step-matcher.js"; +import { resetDatabase } from "../test-support/reset-db.js"; + +describe("tech-step-matcher", () => { + describe("normalizeText", () => { + it("lowercases and strips accents", () => { + expect(normalizeText("Déglacer AU FOUR")).to.equal("deglacer au four"); + }); + + it("strips a variety of diacritics, including cedilla", () => { + expect(normalizeText("Façon Œuf à l'Étouffée")).to.equal("facon œuf a l'etouffee"); + }); + + it("leaves already-plain text unchanged, aside from casing", () => { + expect(normalizeText("Mix everything")).to.equal("mix everything"); + }); + + it("returns an empty string for an empty input", () => { + expect(normalizeText("")).to.equal(""); + }); + }); + + describe("matchTechSteps", () => { + const simmer: TechStepMappingRule = { + techStepId: 1, + expression: "\\bmijot(er|ez|e|ant|é)\\b", + weight: 15, + }; + const cook: TechStepMappingRule = { + techStepId: 2, + expression: "\\bcui(re|sez|sant|sson)\\b|\\bcuit(e|es|s)?\\b", + weight: 10, + }; + const bake: TechStepMappingRule = { + techStepId: 3, + expression: + "\\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(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(matchTechSteps("FAIRE MIJOTER", [simmer])).to.deep.equal([1]); + expect(matchTechSteps("faire mijote", [simmer])).to.deep.equal([1]); + }); + + it("returns an empty sequence when nothing matches", () => { + expect(matchTechSteps("Servir immédiatement", [simmer, cook, bake])).to.deep.equal([]); + }); + + it("returns an empty sequence for an empty mappings list", () => { + expect(matchTechSteps("Faire mijoter à feu doux", [])).to.deep.equal([]); + }); + + it("returns an empty sequence for an empty description", () => { + expect(matchTechSteps("", [simmer, cook, bake])).to.deep.equal([]); + }); + + 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(matchTechSteps("Cuire au four pendant 30 minutes", [bake, cook])).to.deep.equal([3]); + }); + + 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(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", () => { + const wholeWord: TechStepMappingRule = { + techStepId: 7, + expression: "\\bmijoter\\b", + weight: 15, + }; + const withAdverb: TechStepMappingRule = { + techStepId: 7, + expression: "\\bmijoter à feu doux\\b", + weight: 15, + }; + 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(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(matchTechSteps("Faire cuire la sauce", [cook])).to.deep.equal([2]); + expect(matchTechSteps("Le riz est cuit", [cook])).to.deep.equal([2]); + }); + }); + + describe("loadTechStepMappingRules", () => { + beforeEach(async () => { + await resetDatabase(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + it("only returns mappings for the requested locale", async () => { + const simmer = await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } }); + await prisma.techStepMapping.create({ + data: { techStepId: simmer.id, locale: "en", expression: "\\bsimmer\\b", weight: 15 }, + }); + + // 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(26); + expect(frRules.map((rule) => rule.expression)).to.not.include("\\bsimmer\\b"); + + const enRules = await loadTechStepMappingRules("en"); + expect(enRules).to.deep.equal([ + { techStepId: simmer.id, expression: "\\bsimmer\\b", weight: 15 }, + ]); + }); + + it("returns an empty list for a locale with no mappings at all", async () => { + expect(await loadTechStepMappingRules("de")).to.deep.equal([]); + }); + }); +}); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index 6a444ac..a5b3d24 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -343,6 +343,34 @@ "pescatarian": "Pescétarien", "glutenFree": "Sans gluten" }, + "techSteps": { + "cook": "Cuire", + "fry": "Frire", + "melt": "Faire fondre", + "deglaze": "Déglacer", + "simmer": "Mijoter", + "boil": "Bouillir", + "roast": "Rôtir", + "grill": "Griller", + "panFry": "Sauter", + "blanch": "Blanchir", + "marinate": "Mariner", + "chop": "Hacher", + "peel": "Éplucher", + "mince": "Émincer", + "mix": "Mélanger", + "whisk": "Fouetter", + "foldIn": "Incorporer", + "setAside": "Réserver", + "season": "Assaisonner", + "drain": "Égoutter", + "brown": "Faire revenir", + "rest": "Laisser reposer", + "preheat": "Préchauffer", + "bake": "Cuire au four", + "plate": "Dresser", + "coat": "Napper" + }, "allergens": { "gluten": "Gluten", "crustaceans": "Crustacés", 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 5af48e3..bb47423 100644 --- a/packages/shared/src/types/reference.ts +++ b/packages/shared/src/types/reference.ts @@ -187,6 +187,29 @@ export interface UnitView { toBaseFactor: number; } +/** + * A cooking technique, as returned by `GET /reference/tech-steps` — + * reference data (`TechStep`, seeded via `reference-seed-data.ts`'s + * `TECH_STEPS`), same static/non-administrable status as + * {@link DietView}/{@link UnitView}. + * + * 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`'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 + * {@link DietView.key}. + */ +export interface TechStepView { + id: number; + key: string; +} + /** * A selectable ingredient, as returned by `GET /reference/ingredients` — * reference data (`Ingredient`, seeded via `apps/api/src/db/