fix(recipes): une étape peut porter une séquence de tech steps
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 <noreply@anthropic.com>
This commit is contained in:
parent
ea1dfc5ad7
commit
3eadb4db41
12 changed files with 299 additions and 86 deletions
|
|
@ -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;
|
||||||
|
|
@ -564,7 +564,7 @@ model TechStep {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String @unique
|
key String @unique
|
||||||
|
|
||||||
steps Step[]
|
steps StepTechStep[]
|
||||||
mappings TechStepMapping[]
|
mappings TechStepMapping[]
|
||||||
|
|
||||||
@@map("tech_step")
|
@@map("tech_step")
|
||||||
|
|
@ -573,9 +573,11 @@ model TechStep {
|
||||||
/// Used by `tech-step-matcher.ts` to auto-detect which technique a recipe
|
/// Used by `tech-step-matcher.ts` to auto-detect which technique a recipe
|
||||||
/// step's description corresponds to (expression = regex pattern tested
|
/// step's description corresponds to (expression = regex pattern tested
|
||||||
/// against the description, weight = tie-break score when several
|
/// against the description, weight = tie-break score when several
|
||||||
/// mappings match). `locale` (e.g. `"fr"`) lets the same TechStep carry
|
/// mappings match, or overlap-resolution score when two mappings match the
|
||||||
/// one matching rule set per language — the matcher is always called with
|
/// same span of text — see `matchTechSteps`). `locale` (e.g. `"fr"`) lets
|
||||||
/// a target locale and only considers mappings for that locale.
|
/// 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")
|
||||||
|
|
@ -598,10 +600,30 @@ model Step {
|
||||||
description String
|
description String
|
||||||
picture String?
|
picture String?
|
||||||
order Int
|
order Int
|
||||||
techStepId Int? @map("tech_step_id")
|
|
||||||
|
|
||||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
techStep TechStep? @relation(fields: [techStepId], references: [id], onDelete: SetNull)
|
techSteps StepTechStep[]
|
||||||
|
|
||||||
@@map("step")
|
@@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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,20 +45,23 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }>
|
||||||
|
|
||||||
// Cooking-technique catalog (French recipe-step normalization) — a static
|
// Cooking-technique catalog (French recipe-step normalization) — a static
|
||||||
// list of common instructions, each carrying one or more text-matching
|
// list of common instructions, each carrying one or more text-matching
|
||||||
// rules used by `tech-step-matcher.ts` to auto-detect which technique a
|
// rules used by `tech-step-matcher.ts` to auto-detect which technique(s) a
|
||||||
// free-text `Step.description` corresponds to. Same "English camelCase
|
// free-text `Step.description` corresponds to (a step can mention several,
|
||||||
// uid, no French label" authoring as DIETS/UNITS — the label lives in
|
// e.g. "faire chauffer une poêle puis y faire fondre le beurre" is both
|
||||||
// apps/web's locales/fr/translation.json under `catalog.techSteps.<key>`.
|
// `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.<key>`.
|
||||||
// `expression` is a regex source matched (case/accent-insensitive, via
|
// `expression` is a regex source matched (case/accent-insensitive, via
|
||||||
// `normalizeText`) against the step description; `weight` breaks ties when
|
// `normalizeText`) against the step description; `weight` breaks ties when
|
||||||
// a description matches more than one technique's expression (highest
|
// two *different* techniques' expressions match the same span of text
|
||||||
// weight wins) — see `tech-step-matcher.ts`'s `matchTechStep`. Specific,
|
// (highest weight wins) — see `tech-step-matcher.ts`'s `matchTechSteps`.
|
||||||
// multi-word phrases ("cuire au four", "faire revenir") are weighted
|
// Specific, multi-word phrases ("cuire au four", "faire revenir") are
|
||||||
// higher than the generic single-verb forms they overlap with ("cuire",
|
// weighted higher than the generic single-verb forms they overlap with
|
||||||
// "sauter") so the more specific technique wins when both match. `locale`
|
// ("cuire", "sauter") so the more specific technique wins when both match
|
||||||
// lets the same technique carry one matching rule set per language — every
|
// the same words. `locale` lets the same technique carry one matching rule
|
||||||
// entry below is `"fr"` for now, the field exists so other languages can
|
// set per language — every entry below is `"fr"` for now, the field exists
|
||||||
// be added later without a schema change.
|
// so other languages can be added later without a schema change.
|
||||||
export const TECH_STEPS: Array<{
|
export const TECH_STEPS: Array<{
|
||||||
uid: string;
|
uid: string;
|
||||||
mappings: Array<{ locale: string; expression: string; weight: number }>;
|
mappings: Array<{ locale: string; expression: string; weight: number }>;
|
||||||
|
|
@ -73,6 +76,17 @@ export const TECH_STEPS: Array<{
|
||||||
uid: "fry",
|
uid: "fry",
|
||||||
mappings: [{ locale: "fr", expression: "\\bfri(re|t|te|ts|tes|ture)\\b", weight: 15 }],
|
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",
|
uid: "deglaze",
|
||||||
mappings: [{ locale: "fr", expression: "\\bd[ée]glac(er|ez|é|ée|age)\\b", weight: 20 }],
|
mappings: [{ locale: "fr", expression: "\\bd[ée]glac(er|ez|é|ée|age)\\b", weight: 20 }],
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,27 @@
|
||||||
import { prisma } from "../db/prisma.js";
|
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`
|
* step description corresponds to, using the static `TechStepMapping`
|
||||||
* catalog (see `reference-seed-data.ts`'s `TECH_STEPS`) — groundwork for a
|
* catalog (see `reference-seed-data.ts`'s `TECH_STEPS`) — groundwork for a
|
||||||
* future batch-cooking optimization algorithm, not surfaced in the recipe
|
* future batch-cooking optimization algorithm, not surfaced in the recipe
|
||||||
* UI yet (see `StepView` in `packages/shared`).
|
* 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`).
|
* unit-tested in isolation (see `test/tech-step-matcher.test.ts`).
|
||||||
* `loadTechStepMappingRules` is the only DB-touching piece, kept separate
|
* `loadTechStepMappingRules` is the only DB-touching piece, kept separate
|
||||||
* so callers (`recipe.service.ts`) fetch the whole mapping list once per
|
* 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.
|
* once per step.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** One `TechStepMapping` row, trimmed to what {@link matchTechStep} needs. */
|
/** One `TechStepMapping` row, trimmed to what {@link matchTechSteps} needs. */
|
||||||
export interface TechStepMappingRule {
|
export interface TechStepMappingRule {
|
||||||
techStepId: number;
|
techStepId: number;
|
||||||
/**
|
/**
|
||||||
|
|
@ -40,13 +46,40 @@ export function normalizeText(text: string): string {
|
||||||
return text.normalize("NFD").replace(COMBINING_DIACRITICS_PATTERN, "").toLowerCase();
|
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
|
* Detects every technique `description` mentions among `mappings`, as an
|
||||||
* `mappings` — every mapping whose (normalized) `expression` regex tests
|
* ordered sequence of `techStepId`s — empty if none match. The algorithm:
|
||||||
* true against the (normalized) description is a candidate; the candidate
|
*
|
||||||
* with the highest `weight` wins, ties broken by lowest `techStepId`
|
* 1. Test every mapping against the normalized description; each one that
|
||||||
* (equivalent to "first defined in `TECH_STEPS`", since ids are assigned
|
* matches becomes a candidate carrying *where* it matched (so
|
||||||
* in that array's order on first seed). Returns `null` if nothing matches.
|
* 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
|
* Pure — takes `mappings` as a plain argument rather than querying Prisma
|
||||||
* itself, so it's testable without a database (see
|
* 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
|
* 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.
|
* 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);
|
const normalizedDescription = normalizeText(description);
|
||||||
let best: TechStepMappingRule | null = null;
|
|
||||||
|
|
||||||
|
const candidates: MatchCandidate[] = [];
|
||||||
for (const mapping of mappings) {
|
for (const mapping of mappings) {
|
||||||
const pattern = new RegExp(normalizeText(mapping.expression), "i");
|
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<number, MatchCandidate>();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const current = bestByTechStep.get(candidate.techStepId);
|
||||||
if (
|
if (
|
||||||
best === null ||
|
current === undefined ||
|
||||||
mapping.weight > best.weight ||
|
candidate.weight > current.weight ||
|
||||||
(mapping.weight === best.weight && mapping.techStepId < best.techStepId)
|
(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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ 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";
|
import { loadTechStepMappingRules, matchTechSteps } from "../../lib/tech-step-matcher.js";
|
||||||
|
|
||||||
// No user-language preference exists anywhere in the app yet (a single
|
// No user-language preference exists anywhere in the app yet (a single
|
||||||
// "fr" translation file, no locale field on User/UserProfile) — steps are
|
// "fr" translation file, no locale field on User/UserProfile) — steps are
|
||||||
|
|
@ -343,7 +343,12 @@ 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),
|
techSteps: {
|
||||||
|
create: matchTechSteps(step.description, techStepMappings).map((techStepId, order) => ({
|
||||||
|
techStepId,
|
||||||
|
order,
|
||||||
|
})),
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||||
|
|
@ -402,7 +407,14 @@ 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),
|
techSteps: {
|
||||||
|
create: matchTechSteps(step.description, techStepMappings).map(
|
||||||
|
(techStepId, order) => ({
|
||||||
|
techStepId,
|
||||||
|
order,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export async function resetDatabase() {
|
||||||
TRUNCATE TABLE
|
TRUNCATE TABLE
|
||||||
"user_profile_allergy", "user_preference", "allergy", "category",
|
"user_profile_allergy", "user_preference", "allergy", "category",
|
||||||
"planning_item", "planning",
|
"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",
|
"recipe", "ingredients", "sources", "unit",
|
||||||
"user_profiles", "diet", "house"
|
"user_profiles", "diet", "house"
|
||||||
RESTART IDENTITY CASCADE;
|
RESTART IDENTITY CASCADE;
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,16 @@ async function techStepId(key: string): Promise<number> {
|
||||||
return techStep.id;
|
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<number[]> {
|
||||||
|
const links = await prisma.stepTechStep.findMany({
|
||||||
|
where: { stepId },
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
select: { techStepId: true },
|
||||||
|
});
|
||||||
|
return links.map((link) => link.techStepId);
|
||||||
|
}
|
||||||
|
|
||||||
describe("Recipes", () => {
|
describe("Recipes", () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
|
|
@ -239,7 +249,7 @@ 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 () => {
|
it("auto-detects a step's technique from its description and persists it", async () => {
|
||||||
const { agent } = await signup();
|
const { agent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
const piece = await unitId("piece");
|
const piece = await unitId("piece");
|
||||||
|
|
@ -254,12 +264,12 @@ describe("Recipes", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
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 } });
|
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 { agent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
const piece = await unitId("piece");
|
const piece = await unitId("piece");
|
||||||
|
|
@ -273,10 +283,10 @@ describe("Recipes", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
|
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 { agent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
const piece = await unitId("piece");
|
const piece = await unitId("piece");
|
||||||
|
|
@ -300,7 +310,11 @@ describe("Recipes", () => {
|
||||||
where: { recipeId: res.body.id },
|
where: { recipeId: res.body.id },
|
||||||
orderBy: { order: "asc" },
|
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 () => {
|
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);
|
expect(res.status).to.equal(201);
|
||||||
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: res.body.id } });
|
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 () => {
|
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);
|
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 { agent } = await signup();
|
||||||
const tomate = await ingredientId("tomato");
|
const tomate = await ingredientId("tomato");
|
||||||
const piece = await unitId("piece");
|
const piece = await unitId("piece");
|
||||||
|
|
@ -558,7 +593,7 @@ describe("Recipes", () => {
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
expect(res.status).to.equal(200);
|
||||||
const step = await prisma.step.findFirstOrThrow({ where: { recipeId: created.body.id } });
|
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 () => {
|
it("returns 404 RECIPE_NOT_FOUND for an unknown id", async () => {
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ describe("Reference data", () => {
|
||||||
const res = await request(app).get("/reference/tech-steps");
|
const res = await request(app).get("/reference/tech-steps");
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
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.map((t: { key: string }) => t.key)).to.include("simmer");
|
||||||
expect(res.body[0]).to.have.keys(["id", "key"]);
|
expect(res.body[0]).to.have.keys(["id", "key"]);
|
||||||
});
|
});
|
||||||
|
|
@ -121,8 +121,8 @@ describe("Reference data", () => {
|
||||||
await seedReferenceData(prisma);
|
await seedReferenceData(prisma);
|
||||||
|
|
||||||
const res = await request(app).get("/reference/tech-steps");
|
const res = await request(app).get("/reference/tech-steps");
|
||||||
expect(res.body).to.have.length(25);
|
expect(res.body).to.have.length(26);
|
||||||
expect(await prisma.techStepMapping.count()).to.equal(25);
|
expect(await prisma.techStepMapping.count()).to.equal(26);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { prisma } from "../src/db/prisma.js";
|
||||||
import {
|
import {
|
||||||
type TechStepMappingRule,
|
type TechStepMappingRule,
|
||||||
loadTechStepMappingRules,
|
loadTechStepMappingRules,
|
||||||
matchTechStep,
|
matchTechSteps,
|
||||||
normalizeText,
|
normalizeText,
|
||||||
} from "../src/lib/tech-step-matcher.js";
|
} from "../src/lib/tech-step-matcher.js";
|
||||||
import { resetDatabase } from "../test-support/reset-db.js";
|
import { resetDatabase } from "../test-support/reset-db.js";
|
||||||
|
|
@ -27,7 +27,7 @@ describe("tech-step-matcher", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("matchTechStep", () => {
|
describe("matchTechSteps", () => {
|
||||||
const simmer: TechStepMappingRule = {
|
const simmer: TechStepMappingRule = {
|
||||||
techStepId: 1,
|
techStepId: 1,
|
||||||
expression: "\\bmijot(er|ez|e|ant|é)\\b",
|
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",
|
"\\bcuire au four\\b|\\bcuisson au four\\b|\\benfourn(er|ez|é|ée|ées)\\b|\\bau four\\b",
|
||||||
weight: 25,
|
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", () => {
|
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", () => {
|
it("is case- and accent-insensitive, on both the description and the expression itself", () => {
|
||||||
// `simmer`'s own expression source contains a literal "é" — exercises
|
// `simmer`'s own expression source contains a literal "é" — exercises
|
||||||
// normalizeText being applied to the expression, not just the description.
|
// normalizeText being applied to the expression, not just the description.
|
||||||
expect(matchTechStep("FAIRE MIJOTER", [simmer])).to.equal(1);
|
expect(matchTechSteps("FAIRE MIJOTER", [simmer])).to.deep.equal([1]);
|
||||||
expect(matchTechStep("faire mijote", [simmer])).to.equal(1);
|
expect(matchTechSteps("faire mijote", [simmer])).to.deep.equal([1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when nothing matches", () => {
|
it("returns an empty sequence when nothing matches", () => {
|
||||||
expect(matchTechStep("Servir immédiatement", [simmer, cook, bake])).to.be.null;
|
expect(matchTechSteps("Servir immédiatement", [simmer, cook, bake])).to.deep.equal([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for an empty mappings list", () => {
|
it("returns an empty sequence for an empty mappings list", () => {
|
||||||
expect(matchTechStep("Faire mijoter à feu doux", [])).to.be.null;
|
expect(matchTechSteps("Faire mijoter à feu doux", [])).to.deep.equal([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for an empty description", () => {
|
it("returns an empty sequence for an empty description", () => {
|
||||||
expect(matchTechStep("", [simmer, cook, bake])).to.be.null;
|
expect(matchTechSteps("", [simmer, cook, bake])).to.deep.equal([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("picks the highest-weight match when several mappings match", () => {
|
it("detects several distinct, non-overlapping techniques as an ordered sequence", () => {
|
||||||
// "Cuire au four" matches both `cook` (weight 10) and `bake` (weight 25).
|
// The motivating case: "Dans une poêle chaude, faire chauffer une noix
|
||||||
expect(matchTechStep("Cuire au four pendant 30 minutes", [cook, bake])).to.equal(3);
|
// 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.
|
// 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 a: TechStepMappingRule = { techStepId: 5, expression: "\\bmelanger\\b", weight: 10 };
|
||||||
const b: TechStepMappingRule = { techStepId: 2, 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", () => {
|
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",
|
expression: "\\bmijoter à feu doux\\b",
|
||||||
weight: 15,
|
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", () => {
|
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
|
// "recuire"/"précuit" contain "cuire"/"cuit" as a substring, but not as
|
||||||
// a standalone word — the \b-anchored expression must not match them.
|
// a standalone word — the \b-anchored expression must not match them.
|
||||||
expect(matchTechStep("Faire recuire la sauce", [cook])).to.be.null;
|
expect(matchTechSteps("Faire recuire la sauce", [cook])).to.deep.equal([]);
|
||||||
expect(matchTechStep("Un plat précuit", [cook])).to.be.null;
|
expect(matchTechSteps("Un plat précuit", [cook])).to.deep.equal([]);
|
||||||
// The standalone forms still match.
|
// The standalone forms still match.
|
||||||
expect(matchTechStep("Faire cuire la sauce", [cook])).to.equal(2);
|
expect(matchTechSteps("Faire cuire la sauce", [cook])).to.deep.equal([2]);
|
||||||
expect(matchTechStep("Le riz est cuit", [cook])).to.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 },
|
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.
|
// "en" row — same count, and none of them carry the English expression.
|
||||||
const frRules = await loadTechStepMappingRules("fr");
|
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");
|
expect(frRules.map((rule) => rule.expression)).to.not.include("\\bsimmer\\b");
|
||||||
|
|
||||||
const enRules = await loadTechStepMappingRules("en");
|
const enRules = await loadTechStepMappingRules("en");
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,7 @@
|
||||||
"techSteps": {
|
"techSteps": {
|
||||||
"cook": "Cuire",
|
"cook": "Cuire",
|
||||||
"fry": "Frire",
|
"fry": "Frire",
|
||||||
|
"melt": "Faire fondre",
|
||||||
"deglaze": "Déglacer",
|
"deglaze": "Déglacer",
|
||||||
"simmer": "Mijoter",
|
"simmer": "Mijoter",
|
||||||
"boil": "Bouillir",
|
"boil": "Bouillir",
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,11 @@ export interface RecipeIngredientView {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single preparation step within a recipe, in `order`. `tech_step` is
|
* A single preparation step within a recipe, in `order`. Its detected
|
||||||
* deliberately not surfaced here — it's tied to the (not yet built) recipe
|
* technique sequence (`Step.techSteps`/`StepTechStep` in schema.prisma,
|
||||||
* import pipeline, out of scope for the manually-authored catalog.
|
* 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 {
|
export interface StepView {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
|
||||||
|
|
@ -193,12 +193,13 @@ export interface UnitView {
|
||||||
* `TECH_STEPS`), same static/non-administrable status as
|
* `TECH_STEPS`), same static/non-administrable status as
|
||||||
* {@link DietView}/{@link UnitView}.
|
* {@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`/
|
* server-side at save time (see `recipe.service.ts`'s `createRecipe`/
|
||||||
* `updateRecipe`, via `tech-step-matcher.ts`) but deliberately excluded
|
* `updateRecipe`, via `tech-step-matcher.ts`'s `matchTechSteps`) but
|
||||||
* from `StepView` (see its doc comment in `types/recipe.ts`). This
|
* deliberately excluded from `StepView` (see its doc comment in
|
||||||
* endpoint exists for consistency with the other reference catalogs and
|
* `types/recipe.ts`). This endpoint exists for consistency with the other
|
||||||
* for future admin/inspection tooling.
|
* reference catalogs and for future admin/inspection tooling.
|
||||||
*
|
*
|
||||||
* `key` is a stable English camelCase uid (e.g. `"simmer"`), not a display
|
* `key` is a stable English camelCase uid (e.g. `"simmer"`), not a display
|
||||||
* label — resolved via `t(\`catalog.techSteps.${key}\`)`, same as
|
* label — resolved via `t(\`catalog.techSteps.${key}\`)`, same as
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue