feat(recipes): catalogue de techniques culinaires (tech steps)
Rend opérationnel le squelette TechStep/TechStepMapping/Step.techStepId présent dans le schéma depuis le premier commit mais jamais implémenté : - TechStep gagne un `key` unique (camelCase, même convention que Diet/Unit) ; TechStepMapping gagne un `locale` pour pouvoir porter plusieurs jeux de règles de matching par langue. - Catalogue statique de 25 techniques françaises courantes (Cuire, Frire, Déglacer, Mijoter, ...), chacune associée à une ou plusieurs expressions régulières + un poids, seedées de façon idempotente dans reference-seed-data.ts. - Nouveau moteur de matching (apps/api/src/lib/tech-step-matcher.ts) : normalisation accents/casse (NFD) puis test des expressions, résolution du meilleur match par poids. Pur et testé unitairement. - Câblé dans recipe.service.ts : à la création/modification d'une recette, chaque étape voit son techStepId calculé automatiquement à partir de sa description (locale "fr" en dur pour l'instant, faute de préférence de langue utilisateur dans l'app). - Reste backend-only : StepView n'expose pas encore techStepId, conformément au commentaire existant. - Endpoint GET /reference/tech-steps + TechStepView, en cohérence avec les autres catalogues de référence. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
8884d28341
commit
5026a23bd5
12 changed files with 528 additions and 5 deletions
|
|
@ -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;
|
||||||
|
|
@ -556,8 +556,13 @@ model RecipeIngredient {
|
||||||
@@map("recipe_ingredient")
|
@@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.<key>` (see `reference-seed-data.ts`'s `TECH_STEPS`).
|
||||||
model TechStep {
|
model TechStep {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
key String @unique
|
||||||
|
|
||||||
steps Step[]
|
steps Step[]
|
||||||
mappings TechStepMapping[]
|
mappings TechStepMapping[]
|
||||||
|
|
@ -565,11 +570,16 @@ model TechStep {
|
||||||
@@map("tech_step")
|
@@map("tech_step")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Used by the recipe-import pipeline to auto-detect which technique a raw
|
/// Used by `tech-step-matcher.ts` to auto-detect which technique a recipe
|
||||||
/// instruction step corresponds to (expression = text pattern, weight = match score).
|
/// 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.
|
||||||
model TechStepMapping {
|
model TechStepMapping {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
techStepId Int @map("tech_step_id")
|
techStepId Int @map("tech_step_id")
|
||||||
|
locale String
|
||||||
expression String
|
expression String
|
||||||
weight Int
|
weight Int
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,147 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }>
|
||||||
{ uid: "sprig", type: "COUNT", toBaseFactor: 1 },
|
{ 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 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.<key>`.
|
||||||
|
// `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.
|
||||||
|
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: "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
|
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
|
||||||
// businesses to declare — a standard, defensible reference list rather than
|
// businesses to declare — a standard, defensible reference list rather than
|
||||||
// an invented one. Split into ALLERGY (classic IgE-mediated immune
|
// an invented one. Split into ALLERGY (classic IgE-mediated immune
|
||||||
|
|
@ -1125,6 +1266,39 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// `Allergy` itself carries no `key` — it's the selectable instance of a
|
||||||
// keyed `Category` (see schema.prisma) — so seeding an allergen means one
|
// keyed `Category` (see schema.prisma) — so seeding an allergen means one
|
||||||
// Category (upserted by key) plus exactly one Allergy row under it,
|
// Category (upserted by key) plus exactly one Allergy row under it,
|
||||||
|
|
|
||||||
93
apps/api/src/lib/tech-step-matcher.ts
Normal file
93
apps/api/src/lib/tech-step-matcher.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { prisma } from "../db/prisma.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-detects which cooking technique (`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
|
||||||
|
* 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
|
||||||
|
* once per step.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One `TechStepMapping` row, trimmed to what {@link matchTechStep} 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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 matchTechStep(description: string, mappings: TechStepMappingRule[]): number | null {
|
||||||
|
const normalizedDescription = normalizeText(description);
|
||||||
|
let best: TechStepMappingRule | null = null;
|
||||||
|
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
const pattern = new RegExp(normalizeText(mapping.expression), "i");
|
||||||
|
if (!pattern.test(normalizedDescription)) continue;
|
||||||
|
if (
|
||||||
|
best === null ||
|
||||||
|
mapping.weight > best.weight ||
|
||||||
|
(mapping.weight === best.weight && mapping.techStepId < best.techStepId)
|
||||||
|
) {
|
||||||
|
best = mapping;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best?.techStepId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<TechStepMappingRule[]> {
|
||||||
|
return prisma.techStepMapping.findMany({
|
||||||
|
where: { locale },
|
||||||
|
select: { techStepId: true, expression: true, weight: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,14 @@ import {
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
|
import { loadTechStepMappingRules, matchTechStep } 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. */
|
/** 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) {
|
function recipeInclude(viewerId: number) {
|
||||||
|
|
@ -312,6 +320,7 @@ export async function createRecipe(
|
||||||
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||||
await assertDietsExist(input.dietIds);
|
await assertDietsExist(input.dietIds);
|
||||||
|
const techStepMappings = await loadTechStepMappingRules(DEFAULT_TECH_STEP_LOCALE);
|
||||||
|
|
||||||
const created = await prisma.recipe.create({
|
const created = await prisma.recipe.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -334,6 +343,7 @@ export async function createRecipe(
|
||||||
description: step.description,
|
description: step.description,
|
||||||
picture: step.picture ?? null,
|
picture: step.picture ?? null,
|
||||||
order: index,
|
order: index,
|
||||||
|
techStepId: matchTechStep(step.description, techStepMappings),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||||
|
|
@ -366,6 +376,7 @@ export async function updateRecipe(
|
||||||
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||||
await assertDietsExist(input.dietIds);
|
await assertDietsExist(input.dietIds);
|
||||||
|
const techStepMappings = await loadTechStepMappingRules(DEFAULT_TECH_STEP_LOCALE);
|
||||||
|
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }),
|
prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }),
|
||||||
|
|
@ -391,6 +402,7 @@ export async function updateRecipe(
|
||||||
description: step.description,
|
description: step.description,
|
||||||
picture: step.picture ?? null,
|
picture: step.picture ?? null,
|
||||||
order: index,
|
order: index,
|
||||||
|
techStepId: matchTechStep(step.description, techStepMappings),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import { Router } from "express";
|
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
|
* Router mounted at `/reference` in app.ts. Every route is deliberately
|
||||||
|
|
@ -40,3 +46,10 @@ referenceRouter.get(
|
||||||
res.status(200).json(await getUnits());
|
res.status(200).json(await getUnits());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
referenceRouter.get(
|
||||||
|
"/tech-steps",
|
||||||
|
wrapAsyncHandler(async (_req, res) => {
|
||||||
|
res.status(200).json(await getTechSteps());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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";
|
import { prisma } from "../../db/prisma.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -49,6 +55,15 @@ export async function getUnits(): Promise<UnitView[]> {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<TechStepView[]> {
|
||||||
|
return prisma.techStep.findMany({ orderBy: { key: "asc" } });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All reference ingredients, ordered by key (see {@link getDiets} for why),
|
* All reference ingredients, ordered by key (see {@link getDiets} for why),
|
||||||
* each resolved to its allergens (see `IngredientAllergy` in schema.prisma)
|
* each resolved to its allergens (see `IngredientAllergy` in schema.prisma)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,12 @@ async function unitId(key: string): Promise<number> {
|
||||||
return unit.id;
|
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<number> {
|
||||||
|
const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } });
|
||||||
|
return techStep.id;
|
||||||
|
}
|
||||||
|
|
||||||
describe("Recipes", () => {
|
describe("Recipes", () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
|
|
@ -233,6 +239,43 @@ describe("Recipes", () => {
|
||||||
expect(houseRes.body.id).to.be.a("number"); // house exists, sanity check
|
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 () => {
|
||||||
|
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);
|
||||||
|
// techStepId isn't 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves techStepId null when a step's 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(step.techStepId).to.be.null;
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
|
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const piece = await unitId("piece");
|
const piece = await unitId("piece");
|
||||||
|
|
@ -445,6 +488,32 @@ describe("Recipes", () => {
|
||||||
expect(res.body.steps).to.have.length(2);
|
expect(res.body.steps).to.have.length(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("recomputes techStepId for the replaced steps", 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(step.techStepId).to.equal(mince);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
|
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
|
|
|
||||||
|
|
@ -96,4 +96,15 @@ describe("Reference data", () => {
|
||||||
expect(byKey("pinch")).to.include({ type: "COUNT", toBaseFactor: 1 });
|
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(25);
|
||||||
|
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer");
|
||||||
|
expect(res.body[0]).to.have.keys(["id", "key"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
59
apps/api/test/tech-step-matcher.test.ts
Normal file
59
apps/api/test/tech-step-matcher.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { expect } from "chai";
|
||||||
|
import {
|
||||||
|
type TechStepMappingRule,
|
||||||
|
matchTechStep,
|
||||||
|
normalizeText,
|
||||||
|
} from "../src/lib/tech-step-matcher.js";
|
||||||
|
|
||||||
|
describe("tech-step-matcher", () => {
|
||||||
|
describe("normalizeText", () => {
|
||||||
|
it("lowercases and strips accents", () => {
|
||||||
|
expect(normalizeText("Déglacer AU FOUR")).to.equal("deglacer au four");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("matchTechStep", () => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("matches an exact expression", () => {
|
||||||
|
expect(matchTechStep("Faire mijoter à feu doux", [simmer])).to.equal(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case- and accent-insensitive", () => {
|
||||||
|
expect(matchTechStep("FAIRE MIJOTER", [simmer])).to.equal(1);
|
||||||
|
expect(matchTechStep("faire mijoter", [simmer])).to.equal(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when nothing matches", () => {
|
||||||
|
expect(matchTechStep("Servir immédiatement", [simmer, cook, bake])).to.be.null;
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
// Order-independent.
|
||||||
|
expect(matchTechStep("Cuire au four pendant 30 minutes", [bake, cook])).to.equal(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("breaks a 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -343,6 +343,33 @@
|
||||||
"pescatarian": "Pescétarien",
|
"pescatarian": "Pescétarien",
|
||||||
"glutenFree": "Sans gluten"
|
"glutenFree": "Sans gluten"
|
||||||
},
|
},
|
||||||
|
"techSteps": {
|
||||||
|
"cook": "Cuire",
|
||||||
|
"fry": "Frire",
|
||||||
|
"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": {
|
"allergens": {
|
||||||
"gluten": "Gluten",
|
"gluten": "Gluten",
|
||||||
"crustaceans": "Crustacés",
|
"crustaceans": "Crustacés",
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,28 @@ export interface UnitView {
|
||||||
toBaseFactor: number;
|
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 — `Step.techStepId` 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.
|
||||||
|
*
|
||||||
|
* `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` —
|
* A selectable ingredient, as returned by `GET /reference/ingredients` —
|
||||||
* reference data (`Ingredient`, seeded via `apps/api/src/db/
|
* reference data (`Ingredient`, seeded via `apps/api/src/db/
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue