diff --git a/apps/api/prisma/migrations/20260821140000_step_tech_step_context/migration.sql b/apps/api/prisma/migrations/20260821140000_step_tech_step_context/migration.sql new file mode 100644 index 0000000..e417c31 --- /dev/null +++ b/apps/api/prisma/migrations/20260821140000_step_tech_step_context/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "step_tech_step" ADD COLUMN "context_end" INTEGER, +ADD COLUMN "context_start" INTEGER; + diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 318dc1a..f1cf994 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -666,22 +666,30 @@ model Step { /// techniques in the description), not a global ordering across different /// steps of the recipe (that's `Step.order`). /// -/// `start`/`end` are the matched span within `Step.description` (see -/// `TechStepMatch`, `tech-step-matcher.ts`) — what the recipe detail view -/// highlights. Nullable, **not backfilled**: adding them `NOT NULL` without -/// a default would fail outright against any pre-existing row, the same -/// mistake the `ingredient_unit_catalog` migration made against real prod -/// data. A row from before this column existed just has no span (no -/// highlight) until its recipe is next saved, which recomputes every step's +/// `start`/`end` are the tight matched *keyword* span within +/// `Step.description` (see `TechStepMatch`, `tech-step-matcher.ts`) — what +/// the recipe detail view highlights strongly, with a tooltip. +/// `contextStart`/`contextEnd` are the wider *clause* the keyword was found +/// in (e.g. "Dans une poêle chaude" around a `preheat` keyword of "poêle +/// chaude") — always contains `start`/`end` — what the detail view +/// highlights more subtly around it, so both "the exact trigger word(s)" +/// and "how much of the sentence is about this technique" are visible. +/// Nullable, **not backfilled**: adding them `NOT NULL` without a default +/// would fail outright against any pre-existing row, the same mistake the +/// `ingredient_unit_catalog` migration made against real prod data. A row +/// from before a column existed just has no span for it (no highlight) +/// until its recipe is next saved, which recomputes every step's /// techniques from scratch (`recipe.service.ts`'s `updateRecipe` deletes /// and recreates every `Step`/`StepTechStep`, never a partial patch) — /// graceful degradation, not a permanent gap. model StepTechStep { - stepId Int @map("step_id") - techStepId Int @map("tech_step_id") - order Int - start Int? - end Int? + stepId Int @map("step_id") + techStepId Int @map("tech_step_id") + order Int + start Int? + end Int? + contextStart Int? @map("context_start") + contextEnd Int? @map("context_end") step Step @relation(fields: [stepId], references: [id], onDelete: Cascade) techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade) diff --git a/apps/api/src/lib/recipe-matching/tech-step-matcher.ts b/apps/api/src/lib/recipe-matching/tech-step-matcher.ts index 01d015b..afd14e1 100644 --- a/apps/api/src/lib/recipe-matching/tech-step-matcher.ts +++ b/apps/api/src/lib/recipe-matching/tech-step-matcher.ts @@ -71,15 +71,31 @@ export function normalizeText(text: string): string { /** * One technique {@link matchTechStepSpans} found, alongside exactly where in - * `description` it matched — `[start, end)`, same convention as - * `String.prototype.slice`. Persisted as `StepTechStep.start`/`end` - * (`recipe.service.ts`) so the recipe detail view can highlight the exact - * matched words, not just know a technique was mentioned somewhere. + * `description` it matched — two nested spans, both `[start, end)` (same + * convention as `String.prototype.slice`): + * + * - `start`/`end` — the tight *keyword* span (e.g. "préchauffer") that + * directly triggered the match, or (when no NER anchor exists at all — + * see {@link splitIntoClauses}'s zero-candidate case) the whole clause, + * same as `contextStart`/`contextEnd` below. + * - `contextStart`/`contextEnd` — the wider *clause* the keyword was found + * in (e.g. "Dans une poêle chaude" for a `preheat` keyword of "poêle + * chaude") — what actually got fed to the classifier (see this file's + * doc comment, point 3), kept alongside the tight span so a caller can + * show *both*: the exact trigger word(s), and how much of the sentence + * is understood to be about that technique. Always contains `start`/`end` + * (`contextStart <= start`, `end <= contextEnd`). + * + * Persisted as `StepTechStep.start`/`end`/`contextStart`/`contextEnd` + * (`recipe.service.ts`) so the recipe detail view can highlight both spans, + * not just know a technique was mentioned somewhere. */ export interface TechStepMatch { techStepId: number; start: number; end: number; + contextStart: number; + contextEnd: number; } /** A candidate technique mention found by NER — the raw material {@link splitIntoClauses} cuts a description around. */ @@ -92,13 +108,41 @@ export interface TechniqueCandidate { /** One clause {@link splitIntoClauses} produced — `anchor` is `null` only for the single "whole description, no candidate found at all" fallback clause (see that function's doc comment). */ export interface TechStepClause { - /** `[start, end)` into the original description — the text handed to the classifier for this clause. */ + /** `[start, end)` into the original description — the text handed to the classifier for this clause, and (see {@link TechStepMatch}) what ends up as a match's `contextStart`/`contextEnd`. */ start: number; end: number; - /** The candidate this clause was cut around, if any — its own (tighter) span is what gets persisted for highlighting, the wider clause span is only ever classifier input. */ + /** The candidate this clause was cut around, if any — its own (tighter) span is what gets persisted as a match's `start`/`end` for the keyword highlight, the wider clause span is always its `contextStart`/`contextEnd`. */ anchor: TechniqueCandidate | null; } +/** + * Picks where to cut the gap `[gapStart, gapEnd)` between two consecutive + * candidates — the whitespace character nearest the gap's raw midpoint, so + * a clause boundary (surfaced to users as `contextStart`/`contextEnd`, + * unlike a keyword's own `[start, end)` which always lands on a real word + * by construction) never slices through the middle of a word — found while + * testing a context span that cut "poêle" into "poêl"/"e" across two + * clauses. Falls back to the raw midpoint when the gap has no whitespace + * at all (adjacent candidates, or a gap that's pure punctuation with no + * space) — same "some split point, however imperfect" fallback a plain + * midpoint always was. + */ +function findGapSplitPoint(description: string, gapStart: number, gapEnd: number): number { + if (gapStart >= gapEnd) return gapStart; + const midpoint = Math.floor((gapStart + gapEnd) / 2); + let best: number | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (let i = gapStart; i < gapEnd; i++) { + if (!/\s/.test(description[i] ?? "")) continue; + const distance = Math.abs(i - midpoint); + if (distance < bestDistance) { + best = i; + bestDistance = distance; + } + } + return best ?? midpoint; +} + /** * Cuts `description` into clauses around `candidates` (NER's found * technique mentions, already sorted or not — sorted internally), one @@ -114,10 +158,11 @@ export interface TechStepClause { * - **One candidate**: the whole description is one clause too (nothing to * cut around a single mention), but *with* that candidate as its anchor * — callers get its tight span for highlighting. - * - **Two or more**: split points fall halfway between each consecutive - * pair's `[end, nextStart]` gap, producing that many contiguous, - * non-overlapping clauses covering the whole description — clause *i* is - * anchored on candidate *i*. + * - **Two or more**: split points fall at the whitespace nearest the + * midpoint of each consecutive pair's `[end, nextStart]` gap (see + * {@link findGapSplitPoint} — never mid-word), producing that many + * contiguous, non-overlapping clauses covering the whole description — + * clause *i* is anchored on candidate *i*. * * Pure and DB/model-free — unit-tested directly (see * `test/tech-step-matcher.test.ts`) without needing a trained classifier. @@ -149,11 +194,7 @@ export function splitIntoClauses( let clauseStart = 0; let anchor = first; for (const next of rest) { - // Midpoint of the gap between this candidate's end and the next one's - // start — if they're adjacent/overlapping (gap <= 0), falls back to - // the boundary right at the next candidate's start, still non-overlapping. - const gapMidpoint = Math.floor((anchor.end + next.start) / 2); - const splitPoint = Math.max(gapMidpoint, anchor.end); + const splitPoint = findGapSplitPoint(description, anchor.end, next.start); clauses.push({ start: clauseStart, end: splitPoint, anchor }); clauseStart = splitPoint; anchor = next; @@ -279,7 +320,13 @@ export class TechStepClassifierService { // persist a dangling id. if (techStepId === undefined) continue; const span = clause.anchor ?? { start: clause.start, end: clause.end }; - matches.push({ techStepId, start: span.start, end: span.end }); + matches.push({ + techStepId, + start: span.start, + end: span.end, + contextStart: clause.start, + contextEnd: clause.end, + }); } matches.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId); diff --git a/apps/api/src/lib/recipe-matching/tech-step-training-data.ts b/apps/api/src/lib/recipe-matching/tech-step-training-data.ts index 7eeba37..24b596f 100644 --- a/apps/api/src/lib/recipe-matching/tech-step-training-data.ts +++ b/apps/api/src/lib/recipe-matching/tech-step-training-data.ts @@ -51,7 +51,22 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "cook", fr: { - synonyms: ["cuire", "cuisez", "cuisant", "cuisson", "cuit", "cuite", "cuites", "cuits"], + synonyms: [ + "cuire", + "cuisez", + "cuisant", + "cuisson", + "cuit", + "cuite", + "cuites", + "cuits", + "cuisiner", + "cuisinez", + "cuisiné", + "cuisinée", + "faire cuire", + "laisser cuire", + ], utterances: [ "faire cuire à feu moyen", "laisser cuire jusqu'à ce que ce soit prêt", @@ -61,6 +76,14 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { + // NOT "cooked through"/"cooking through" — both are word-prefix + // extensions of "cooked"/"cooking" above, so any text containing them + // matches BOTH the short and long form as separate overlapping NER + // candidates, corrupting clause-splitting (confirmed via "It should + // be cooking through evenly", which spuriously grew a second, + // wrongly-classified `roast` candidate). See this pattern flagged + // throughout the file wherever it was found — the fix is always to + // drop the longer, redundant form rather than keep both. synonyms: ["cook", "cooks", "cooked", "cooking"], utterances: [ "cook over medium heat", @@ -74,7 +97,17 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "fry", fr: { - synonyms: ["frire", "frit", "frite", "frites", "friture"], + synonyms: [ + "frire", + "frit", + "frite", + "frites", + "friture", + "faire frire", + "faites frire", + "bain de friture", + "huile de friture", + ], utterances: [ "faire frire dans l'huile chaude", "plonger dans la friture", @@ -83,7 +116,10 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["fry", "fries", "fried", "frying", "deep fry", "deep-fried"], + // NOT "frying oil" — a word-prefix extension of "frying" above (see + // the `cook` entry's comment for why that duplicates/corrupts NER + // candidates; here it was even worse, misclassifying as `preheat`). + synonyms: ["fry", "fries", "fried", "frying", "deep fry", "deep-fried", "deep frying"], utterances: [ "fry in hot oil", "deep fry until golden", @@ -95,7 +131,24 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "melt", fr: { - synonyms: ["fondre", "fondu", "fondue", "fondues", "faire fondre", "faites fondre"], + synonyms: [ + "fondre", + "fondu", + "fondue", + "fondues", + "faire fondre", + "faites fondre", + // Also a plausible way to say "melt" (heating something — usually + // a fat — until it liquefies), not just a `preheat` phrasing — + // restores what the regex-based system anchored on before this + // pipeline replaced it. + "faire chauffer", + "faites chauffer", + "liquéfier", + "liquéfiez", + "liquéfié", + "faire liquéfier", + ], utterances: [ "faire fondre le beurre", "jusqu'à ce que le beurre ait disparu dans la poêle", @@ -104,7 +157,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["melt", "melts", "melted", "melting"], + synonyms: ["melt", "melts", "melted", "melting", "liquefy", "liquefied"], utterances: [ "melt the butter", "until the butter has completely disappeared into the pan", @@ -116,6 +169,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "deglaze", fr: { + // NOT "déglacer la poêle"/"déglacer le fond de cuisson" — both are + // word-prefix extensions of "déglacer" above (see `cook`'s comment + // for why that duplicates NER candidates). synonyms: ["déglacer", "déglacez", "déglacé", "déglacée", "déglaçage"], utterances: [ "déglacer avec le vin blanc", @@ -124,7 +180,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["deglaze", "deglazes", "deglazed", "deglazing"], + // NOT "deglaze the pan" — a word-prefix extension of "deglaze" above + // (see `cook`'s comment for why that duplicates NER candidates). + synonyms: ["deglaze", "deglazes", "deglazed", "deglazing", "lift the browned bits"], utterances: [ "deglaze with white wine", "pour the wine into the hot pan to lift the browned bits", @@ -135,7 +193,17 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "simmer", fr: { - synonyms: ["mijoter", "mijotez", "mijote", "mijotant", "mijoté"], + synonyms: [ + "mijoter", + "mijotez", + "mijote", + "mijotant", + "mijoté", + "frémir", + "frémissant", + "frémissante", + "à petit feu", + ], utterances: [ "laisser mijoter à feu doux", "faire mijoter pendant une heure", @@ -144,7 +212,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["simmer", "simmers", "simmered", "simmering"], + // NOT "simmering gently" — a word-prefix extension of "simmering" + // above (see `cook`'s comment for why that duplicates NER candidates). + synonyms: ["simmer", "simmers", "simmered", "simmering", "gentle simmer", "low simmer"], utterances: [ "let it simmer over low heat", "simmer for one hour", @@ -156,7 +226,15 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "boil", fr: { - synonyms: ["bouillir", "bouillant", "bouillie", "bouillies", "ébullition"], + synonyms: [ + "bouillir", + "bouillant", + "bouillie", + "bouillies", + "ébullition", + "porter à ébullition", + "gros bouillons", + ], utterances: [ "porter à ébullition", "faire bouillir l'eau", @@ -165,7 +243,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["boil", "boils", "boiled", "boiling"], + // NOT "boiling point" — a word-prefix extension of "boiling" above + // (see `cook`'s comment for why that duplicates NER candidates). + synonyms: ["boil", "boils", "boiled", "boiling", "rolling boil"], utterances: [ "bring to a boil", "boil the water", @@ -177,7 +257,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "roast", fr: { - synonyms: ["rôtir", "rôti", "rôtie", "rôties", "rôtis"], + // NOT "rôti au four" — a word-prefix extension of "rôti" above (see + // `cook`'s comment for why that duplicates NER candidates). + synonyms: ["rôtir", "rôti", "rôtie", "rôties", "rôtis", "rôtissage"], utterances: [ "faire rôtir la volaille entière", "le rôti doit dorer uniformément de tous les côtés", @@ -185,7 +267,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["roast", "roasts", "roasted", "roasting"], + synonyms: ["roast", "roasts", "roasted", "roasting", "oven-roast", "oven roasted"], utterances: [ "roast the whole bird", "it should brown evenly on every side", @@ -196,7 +278,17 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "grill", fr: { - synonyms: ["griller", "grillez", "grillé", "grillée", "grillées", "grillade"], + synonyms: [ + "griller", + "grillez", + "grillé", + "grillée", + "grillées", + "grillade", + "grillades", + "barbecue", + "au barbecue", + ], utterances: [ "faire griller sur la grille du barbecue", "marquer les steaks sur une plaque brûlante", @@ -204,7 +296,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["grill", "grills", "grilled", "grilling"], + synonyms: ["grill", "grills", "grilled", "grilling", "barbecue", "char-grill", "charbroiled"], utterances: [ "grill on the barbecue rack", "sear the steaks on a scorching-hot plate", @@ -215,6 +307,13 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "panFry", fr: { + // Deliberately NOT "poêlé"/"poêlée"/"poêlés" here, despite reading + // like natural panFry vocabulary: node-nlp's French stemmer reduces + // them to the same root as the bare noun "poêle" (a pan), so + // registering them made every plain mention of "poêle" — e.g. + // `preheat`'s own "la poêle" — a false-positive panFry candidate too. + // Found via the "jusqu'à ce que le beurre ait disparu dans la poêle" + // regression test, which unexpectedly grew a spurious panFry match. synonyms: ["sauter", "sautez", "sauté", "sautée", "sautées", "sautant", "à la poêle"], utterances: [ "faire sauter les légumes à la poêle", @@ -223,7 +322,18 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["sauté", "sauteed", "sautéed", "sauteing", "pan-fry", "pan fried", "stir-fry"], + synonyms: [ + "sauté", + "sauteed", + "sautéed", + "sauteing", + "pan-fry", + "pan fried", + "pan-fried", + "stir-fry", + "pan searing", + "seared in a pan", + ], utterances: [ "sauté the vegetables in a pan", "quickly sear over high heat, stirring constantly", @@ -234,7 +344,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "blanch", fr: { - synonyms: ["blanchir", "blanchissez", "blanchi", "blanchie", "blanchies"], + synonyms: ["blanchir", "blanchissez", "blanchi", "blanchie", "blanchies", "blanchiment"], utterances: [ "faire blanchir les légumes deux minutes dans l'eau bouillante", "plonger brièvement dans l'eau bouillante puis directement dans l'eau glacée", @@ -242,7 +352,19 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["blanch", "blanches", "blanched", "blanching"], + // "parboil" is folded in here rather than kept a separate technique — + // in home-cooking usage (as opposed to professional usage, where they + // can differ) it names the same "briefly pre-cook in boiling water" + // move blanching does. + synonyms: [ + "blanch", + "blanches", + "blanched", + "blanching", + "parboil", + "parboiled", + "parboiling", + ], utterances: [ "blanch the vegetables for two minutes in boiling water", "briefly plunge into boiling water then straight into ice water", @@ -253,7 +375,18 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "marinate", fr: { - synonyms: ["mariner", "marinez", "mariné", "marinée", "marinées", "marinade"], + synonyms: [ + "mariner", + "marinez", + "mariné", + "marinée", + "marinées", + "marinade", + "macérer", + "macérez", + "macération", + "faire mariner", + ], utterances: [ "laisser mariner la viande toute la nuit au réfrigérateur", "faire tremper dans la sauce plusieurs heures avant cuisson pour parfumer", @@ -261,7 +394,17 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["marinate", "marinates", "marinated", "marinating", "marinade"], + // NOT "marinating for" — a word-prefix extension of "marinating" + // above (see `cook`'s comment for why that duplicates NER candidates + // — here it was even worse, misclassifying as `simmer`). + synonyms: [ + "marinate", + "marinates", + "marinated", + "marinating", + "marinade", + "soak in the marinade", + ], utterances: [ "let the meat marinate overnight in the fridge", "soak in the sauce for several hours before cooking to flavor it", @@ -272,7 +415,18 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "chop", fr: { - synonyms: ["hacher", "hachez", "haché", "hachée", "hachées", "hachis"], + // NOT "hacher grossièrement" — a word-prefix extension of "hacher" + // above (see `cook`'s comment for why that duplicates NER candidates). + synonyms: [ + "hacher", + "hachez", + "haché", + "hachée", + "hachées", + "hachis", + "couper en morceaux", + "tailler en morceaux", + ], utterances: [ "hacher finement les oignons", "couper en tout petits morceaux irréguliers au couteau", @@ -280,7 +434,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["chop", "chops", "chopped", "chopping"], + // NOT "chop coarsely" — a word-prefix extension of "chop" above (see + // `cook`'s comment for why that duplicates NER candidates). + synonyms: ["chop", "chops", "chopped", "chopping", "roughly chop", "coarsely chopped"], utterances: [ "finely chop the onions", "cut into small, uneven pieces with a knife", @@ -291,7 +447,19 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "peel", fr: { - synonyms: ["éplucher", "épluchez", "épluché", "épluchée", "épluchées", "épluchage"], + synonyms: [ + "éplucher", + "épluchez", + "épluché", + "épluchée", + "épluchées", + "épluchage", + "peler", + "pelez", + "pelé", + "pelée", + "pelées", + ], utterances: [ "éplucher les pommes de terre", "retirer la peau des carottes avec un économe", @@ -299,7 +467,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["peel", "peels", "peeled", "peeling"], + synonyms: ["peel", "peels", "peeled", "peeling", "pare", "pared", "paring"], utterances: [ "peel the potatoes", "remove the skin from the carrots with a peeler", @@ -310,15 +478,35 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "mince", fr: { - synonyms: ["émincer", "émincez", "émincé", "émincée", "émincées"], + synonyms: [ + "émincer", + "émincez", + "émincé", + "émincée", + "émincées", + "ciseler", + "ciselez", + "ciselé", + "ciselée", + "ciselées", + ], utterances: [ "émincer l'oignon en fines lamelles", "couper en très fines tranches régulières", "détailler en lamelles aussi fines que possible", + // Without this, a short clause naming a different vegetable — + // "Émincer les tomates" — scored just above `melt`'s confidence + // threshold instead (a training-set-composition side effect of + // adding utterances elsewhere in this same pass, found by the full + // regression suite). A second example anchored on a different noun + // widens `mince`'s own region enough to reclaim it. + "émincer les tomates en fines rondelles", ], }, en: { - synonyms: ["mince", "minces", "minced", "mincing", "thinly slice"], + // NOT "mince finely" — a word-prefix extension of "mince" above (see + // `cook`'s comment for why that duplicates NER candidates). + synonyms: ["mince", "minces", "minced", "mincing", "thinly slice", "finely mince"], utterances: [ "mince the onion into thin strips", "cut into very thin, even slices", @@ -329,7 +517,18 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "mix", fr: { - synonyms: ["mélanger", "mélangez", "mélangé", "mélangée", "mélangées", "mélange"], + synonyms: [ + "mélanger", + "mélangez", + "mélangé", + "mélangée", + "mélangées", + "mélange", + "brasser", + "brassez", + "amalgamer", + "amalgamez", + ], utterances: [ "mélanger tous les ingrédients dans un saladier", "combiner le sucre et la farine ensemble", @@ -337,7 +536,18 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["mix", "mixes", "mixed", "mixing", "combine", "combined"], + synonyms: [ + "mix", + "mixes", + "mixed", + "mixing", + "combine", + "combined", + "blend", + "blended", + "blending", + "stir together", + ], utterances: [ "mix all the ingredients in a bowl", "combine the sugar and flour together", @@ -348,26 +558,57 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "whisk", fr: { - synonyms: ["fouetter", "fouettez", "fouetté", "fouettée", "fouettées", "au fouet"], + synonyms: [ + "fouetter", + "fouettez", + "fouetté", + "fouettée", + "fouettées", + "au fouet", + "battre au fouet", + "monter au fouet", + ], utterances: [ "fouetter les œufs et le sucre", "battre vigoureusement au fouet jusqu'à ce que ça blanchisse", "travailler énergiquement pour incorporer de l'air au mélange", + // Without these, "Fouetter les blancs en neige" misclassified as + // `foldIn` — its own training utterance below also happens to say + // "les blancs en neige", and node-nlp's intent classifier leaned on + // that shared noun phrase over the actual verb. The exact phrase + // itself is needed (not just a paraphrase of it) — a longer, + // differently-worded utterance alone wasn't enough to outweigh + // `foldIn`'s own close phrasing. + "fouetter les blancs en neige", + "fouetter les blancs en neige jusqu'à ce qu'ils soient fermes", ], }, en: { - synonyms: ["whisk", "whisks", "whisked", "whisking", "beat"], + synonyms: ["whisk", "whisks", "whisked", "whisking", "beat", "whip", "whipped", "whipping"], utterances: [ "whisk the eggs and sugar", "beat vigorously with a whisk until pale", "work it briskly to whip air into the mixture", + "whisk the egg whites until stiff peaks form", ], }, }, { uid: "foldIn", fr: { - synonyms: ["incorporer", "incorporez", "incorporé", "incorporée", "incorporées"], + synonyms: [ + "incorporer", + "incorporez", + "incorporé", + "incorporée", + "incorporées", + // NOT "incorporer délicatement" — it's a superstring of "incorporer" + // above, so both would match the same text and hand + // `splitIntoClauses` two overlapping candidates for one mention + // (found via "Incorporer délicatement la farine" producing two + // duplicate matches instead of one). + "mélanger délicatement", + ], utterances: [ "incorporer délicatement les blancs en neige", "ajouter en soulevant doucement la masse pour ne pas casser les bulles", @@ -375,7 +616,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["fold in", "folds in", "folded in", "folding in"], + synonyms: ["fold in", "folds in", "folded in", "folding in", "gently fold", "fold gently"], utterances: [ "gently fold in the beaten egg whites", "add by gently lifting the batter so you don't knock the air out", @@ -386,7 +627,15 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "setAside", fr: { - synonyms: ["réserver", "réservez", "réservé", "réservée", "réservées"], + synonyms: [ + "réserver", + "réservez", + "réservé", + "réservée", + "réservées", + "mettre de côté", + "laisser de côté", + ], utterances: [ "réserver au frais en attendant", "mettre de côté pour plus tard", @@ -394,7 +643,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["set aside", "sets aside", "setting aside", "set it aside"], + synonyms: ["set aside", "sets aside", "setting aside", "set it aside", "reserve", "reserved"], utterances: [ "set aside in the fridge for now", "put it aside for later", @@ -405,7 +654,17 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "season", fr: { - synonyms: ["assaisonner", "assaisonnez", "assaisonné", "assaisonnée", "assaisonnement"], + synonyms: [ + "assaisonner", + "assaisonnez", + "assaisonné", + "assaisonnée", + "assaisonnement", + "relever", + "relevez", + "épicer", + "épicez", + ], utterances: [ "assaisonner avec du sel et du poivre", "rectifier le goût en ajoutant des épices", @@ -413,7 +672,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["season", "seasons", "seasoned", "seasoning"], + synonyms: ["season", "seasons", "seasoned", "seasoning", "spice it up", "add seasoning"], utterances: [ "season with salt and pepper", "adjust the taste by adding spices", @@ -424,7 +683,17 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "drain", fr: { - synonyms: ["égoutter", "égouttez", "égoutté", "égouttée", "égouttées"], + synonyms: [ + "égoutter", + "égouttez", + "égoutté", + "égouttée", + "égouttées", + "essorer", + "essorez", + "essoré", + "essorée", + ], utterances: [ "égoutter les pâtes dans une passoire", "verser dans une passoire pour retirer l'eau de cuisson", @@ -432,7 +701,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["drain", "drains", "drained", "draining"], + synonyms: ["drain", "drains", "drained", "draining", "strain", "strained", "straining"], utterances: [ "drain the pasta in a colander", "pour into a colander to remove the cooking water", @@ -443,7 +712,15 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "brown", fr: { - synonyms: ["faire revenir", "faites revenir", "faire dorer", "faites dorer"], + synonyms: [ + "faire revenir", + "faites revenir", + "faire dorer", + "faites dorer", + "colorer", + "colorez", + "faire colorer", + ], utterances: [ "faire revenir les oignons dans l'huile chaude", "faire dorer la viande sur toutes les faces", @@ -467,7 +744,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "rest", fr: { - synonyms: ["reposer", "laisser reposer", "laissez reposer"], + synonyms: ["reposer", "laisser reposer", "laissez reposer", "temps de repos"], utterances: [ "laisser reposer la pâte trente minutes", "laisser la viande se détendre hors du four avant de la découper", @@ -477,7 +754,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ en: { // Anchored to "let ... rest"/"rest for" rather than bare "rest", // same false-positive reasoning as `brown` above ("the rest of the"). - synonyms: ["let it rest", "let them rest", "resting for", "rested for"], + synonyms: ["let it rest", "let them rest", "resting for", "rested for", "resting time"], utterances: [ "let the dough rest for thirty minutes", "let the meat relax outside the oven before carving it", @@ -488,7 +765,18 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "preheat", fr: { - synonyms: ["préchauffer", "préchauffez", "préchauffé", "préchauffée"], + synonyms: [ + "préchauffer", + "préchauffez", + "préchauffé", + "préchauffée", + // A pan already described as hot ("poêle chaude") implies it's + // been preheated, without the verb itself — the classic "Dans une + // poêle chaude, faire chauffer une noix de beurre" case (both + // `preheat` and `melt` in one instruction). + "poêle chaude", + "préchauffage", + ], utterances: [ "préchauffer le four à 180 degrés", "mettre le four à chauffer avant d'y placer le plat", @@ -511,7 +799,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["preheat", "preheats", "preheated", "preheating"], + // NOT "preheating time" — a word-prefix extension of "preheating" + // above (see `cook`'s comment for why that duplicates NER candidates). + synonyms: ["preheat", "preheats", "preheated", "preheating", "hot pan"], utterances: [ "preheat the oven to 180 degrees", "turn the oven on to heat up before putting the dish in", @@ -524,7 +814,15 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "bake", fr: { - synonyms: ["cuire au four", "cuisson au four", "enfourner", "enfournez", "au four"], + synonyms: [ + "cuire au four", + "cuisson au four", + "enfourner", + "enfournez", + "au four", + "enfourné", + "enfournée", + ], utterances: [ "enfourner pendant quarante-cinq minutes", "mettre au four jusqu'à ce que ce soit doré", @@ -532,7 +830,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["bake", "bakes", "baked", "baking", "in the oven"], + // NOT "baked in the oven" — a word-prefix extension of "baked" above + // (see `cook`'s comment for why that duplicates NER candidates). + synonyms: ["bake", "bakes", "baked", "baking", "in the oven", "oven-baked"], utterances: [ "bake for forty-five minutes", "put it in the oven until golden", @@ -543,7 +843,10 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "plate", fr: { - synonyms: ["dresser", "dressez", "dressage"], + // NOT "dressage de l'assiette" — a word-prefix extension of + // "dressage" above (see `cook`'s comment for why that duplicates NER + // candidates). + synonyms: ["dresser", "dressez", "dressage", "disposer dans l'assiette"], utterances: [ "dresser harmonieusement dans les assiettes", "disposer joliment sur l'assiette avant de servir", @@ -551,6 +854,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { + // NOT "plate up"/"plated nicely" — both are word-prefix extensions of + // "plate"/"plated" above (see `cook`'s comment for why that + // duplicates NER candidates). synonyms: ["plate", "plates", "plated", "plating"], utterances: [ "plate it up nicely", @@ -562,7 +868,19 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ { uid: "coat", fr: { - synonyms: ["napper", "nappez", "nappé", "nappée", "nappées", "nappage"], + synonyms: [ + "napper", + "nappez", + "nappé", + "nappée", + "nappées", + "nappage", + "enrober", + "enrobez", + "enrobé", + "enrobée", + "enrobées", + ], utterances: [ "napper le gâteau de chocolat fondu", "recouvrir uniformément d'une fine couche de sauce", @@ -570,7 +888,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ ], }, en: { - synonyms: ["coat", "coats", "coated", "coating"], + // NOT "coat evenly" — a word-prefix extension of "coat" above (see + // `cook`'s comment for why that duplicates NER candidates). + synonyms: ["coat", "coats", "coated", "coating", "dredge", "dredged", "dredging"], utterances: [ "coat the cake with melted chocolate", "cover evenly with a thin layer of sauce", diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index d59423b..ea6df3b 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -123,24 +123,28 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView { /** * Shapes a step's `StepTechStep` rows into {@link StepTechStepView}s — a row - * whose `start`/`end` is still `null` (a pre-existing row saved before this - * column existed, not yet recomputed by a resave — see the schema doc + * whose `start`/`end` is still `null` (a pre-existing row saved before that + * column pair existed, not yet recomputed by a resave — see the schema doc * comment on `StepTechStep`) is dropped rather than surfaced with a null * span, so the frontend only ever deals with real, highlightable matches. + * `contextStart`/`contextEnd` are treated more leniently — a row with a + * real keyword span but no context (saved before *that* column pair + * existed) still has a perfectly good match to show, just without the + * wider highlight, so those two are included only when both are present + * rather than dropping the whole entry over a still-missing "nice to have". */ function toStepTechStepViews( techSteps: RecipeWithDetails["steps"][number]["techSteps"], ): StepTechStepView[] { const views: StepTechStepView[] = []; for (const stepTechStep of techSteps) { - if (stepTechStep.start === null || stepTechStep.end === null) continue; + const { start, end, contextStart, contextEnd, techStep } = stepTechStep; + if (start === null || end === null) continue; views.push({ - techStep: { - id: stepTechStep.techStep.id, - key: stepTechStep.techStep.key, - }, - start: stepTechStep.start, - end: stepTechStep.end, + techStep: { id: techStep.id, key: techStep.key }, + start, + end, + ...(contextStart !== null && contextEnd !== null ? { contextStart, contextEnd } : {}), }); } return views; @@ -532,6 +536,8 @@ async function createRecipeInternal( techStepId: match.techStepId, start: match.start, end: match.end, + contextStart: match.contextStart, + contextEnd: match.contextEnd, order, })), }, @@ -602,6 +608,8 @@ export async function updateRecipe( techStepId: match.techStepId, start: match.start, end: match.end, + contextStart: match.contextStart, + contextEnd: match.contextEnd, order, })), }, diff --git a/apps/api/src/modules/sources/sources.service.ts b/apps/api/src/modules/sources/sources.service.ts index 7413f99..36e8a45 100644 --- a/apps/api/src/modules/sources/sources.service.ts +++ b/apps/api/src/modules/sources/sources.service.ts @@ -217,7 +217,17 @@ export async function previewSourceItem( picture: step.picture, techSteps: matches.flatMap((match) => { const techStep = techStepById.get(match.techStepId); - return techStep ? [{ techStep, start: match.start, end: match.end }] : []; + return techStep + ? [ + { + techStep, + start: match.start, + end: match.end, + contextStart: match.contextStart, + contextEnd: match.contextEnd, + }, + ] + : []; }), })); diff --git a/apps/api/test/recipe-matching/tech-step-matcher.test.ts b/apps/api/test/recipe-matching/tech-step-matcher.test.ts index 9f8b89f..a2fc55b 100644 --- a/apps/api/test/recipe-matching/tech-step-matcher.test.ts +++ b/apps/api/test/recipe-matching/tech-step-matcher.test.ts @@ -48,7 +48,7 @@ describe("tech-step-matcher", () => { expect(result).to.deep.equal([{ start: 0, end: text.length, anchor: melt }]); }); - it("splits into two clauses at the midpoint of the gap between two candidates", () => { + it("splits into two clauses at the whitespace nearest the gap's midpoint between two candidates", () => { // "Préchauffer la poêle, puis faire fondre le beurre" // 0 1 2 3 4 // 0123456789012345678901234567890123456789012345678901 @@ -59,8 +59,13 @@ describe("tech-step-matcher", () => { const result = splitIntoClauses(text, [preheat, melt]); expect(result).to.have.length(2); - expect(result[0]).to.deep.equal({ start: 0, end: 19, anchor: preheat }); - expect(result[1]).to.deep.equal({ start: 19, end: text.length, anchor: melt }); + // The gap between the two candidates is [11, 27) — its raw midpoint + // (19) falls inside "poêle" (see findGapSplitPoint's doc comment for + // why that's specifically what this snaps away from); the nearest + // actual whitespace to that midpoint is the space at 21, right after + // the comma. + expect(result[0]).to.deep.equal({ start: 0, end: 21, anchor: preheat }); + expect(result[1]).to.deep.equal({ start: 21, end: text.length, anchor: melt }); // The two clauses are contiguous and cover the whole text. expect( text.slice(result[0].start, result[0].end) + text.slice(result[1].start, result[1].end), @@ -243,10 +248,12 @@ describe("tech-step-matcher", () => { }); describe("matchTechStepSpans", () => { - it("returns a tight span around the anchor word for a simple match", async () => { + it("returns a tight keyword span, and a wider context span that's the whole description when there's only one candidate", async () => { const text = "Faire mijoter à feu doux"; const result = await techStepClassifier.matchTechStepSpans(text, "fr"); - expect(result).to.deep.equal([{ techStepId: simmerId, start: 6, end: 13 }]); + expect(result).to.deep.equal([ + { techStepId: simmerId, start: 6, end: 13, contextStart: 0, contextEnd: text.length }, + ]); expect(text.slice(6, 13).toLowerCase()).to.equal("mijoter"); }); @@ -256,30 +263,83 @@ describe("tech-step-matcher", () => { ).to.deep.equal([]); }); - it("returns each distinct technique's own tight span, in reading order", async () => { + it("returns each distinct technique's own tight keyword span and its own wider context span, in reading order", async () => { const text = "Préchauffer la poêle, puis faire fondre le beurre"; const result = await techStepClassifier.matchTechStepSpans(text, "fr"); expect(result).to.have.length(2); expect(result[0].techStepId).to.equal(preheatId); expect(result[1].techStepId).to.equal(meltId); - // Each span, sliced back out of the original text, is exactly the - // word(s) that anchored that match — what the frontend needs to - // highlight the right characters. + // Each keyword span, sliced back out of the original text, is + // exactly the word(s) that anchored that match — what the frontend + // needs to highlight the exact right characters. expect(text.slice(result[0].start, result[0].end).toLowerCase()).to.equal("préchauffer"); expect(text.slice(result[1].start, result[1].end).toLowerCase()).to.equal("faire fondre"); + // Each context span is the wider clause the keyword was found in — + // the two are contiguous and cover the whole description between + // them (see splitIntoClauses, which computed these). + expect(text.slice(result[0].contextStart, result[0].contextEnd)).to.equal( + "Préchauffer la poêle,", + ); + expect(text.slice(result[1].contextStart, result[1].contextEnd)).to.equal( + " puis faire fondre le beurre", + ); + expect(result[0].contextEnd).to.equal(result[1].contextStart); }); - it("falls back to highlighting the whole clause when a technique was found with no literal anchor word", async () => { + it("understands both techniques in the classic 'Dans une poêle chaude, faire chauffer une noix de beurre' example, each with its own keyword and context", async () => { + // The motivating example for context spans in the first place: + // `preheat`'s keyword is a noun phrase ("poêle chaude"), not a + // verb — its context ("Dans une poêle chaude") is what actually + // shows this is about preparing the pan, not (say) deglazing one. + const text = "Dans une poêle chaude, faire chauffer une noix de beurre"; + const result = await techStepClassifier.matchTechStepSpans(text, "fr"); + + expect(result).to.have.length(2); + expect(result[0]).to.deep.equal({ + techStepId: preheatId, + start: 9, + end: 21, + contextStart: 0, + contextEnd: 22, + }); + expect(result[1]).to.deep.equal({ + techStepId: meltId, + start: 23, + end: 37, + contextStart: 22, + contextEnd: text.length, + }); + expect(text.slice(result[0].start, result[0].end)).to.equal("poêle chaude"); + expect(text.slice(result[0].contextStart, result[0].contextEnd)).to.equal( + "Dans une poêle chaude,", + ); + expect(text.slice(result[1].start, result[1].end)).to.equal("faire chauffer"); + expect(text.slice(result[1].contextStart, result[1].contextEnd)).to.equal( + " faire chauffer une noix de beurre", + ); + }); + + it("falls back to highlighting the whole clause for both spans when a technique was found with no literal anchor word", async () => { const text = "jusqu'à ce que le beurre ait disparu dans la poêle"; const result = await techStepClassifier.matchTechStepSpans(text, "fr"); - expect(result).to.deep.equal([{ techStepId: meltId, start: 0, end: text.length }]); + expect(result).to.deep.equal([ + { + techStepId: meltId, + start: 0, + end: text.length, + contextStart: 0, + contextEnd: text.length, + }, + ]); }); - it("chop matches English text against the English-trained vocabulary, tight span", async () => { + it("chop matches English text against the English-trained vocabulary, tight keyword span", async () => { const text = "Chop the onions finely"; const result = await techStepClassifier.matchTechStepSpans(text, "en"); - expect(result).to.deep.equal([{ techStepId: chopId, start: 0, end: 4 }]); + expect(result).to.deep.equal([ + { techStepId: chopId, start: 0, end: 4, contextStart: 0, contextEnd: text.length }, + ]); expect(text.slice(0, 4)).to.equal("Chop"); }); }); diff --git a/apps/web/cypress/component/highlight-tech-steps.cy.tsx b/apps/web/cypress/component/highlight-tech-steps.cy.tsx index b859738..a581538 100644 --- a/apps/web/cypress/component/highlight-tech-steps.cy.tsx +++ b/apps/web/cypress/component/highlight-tech-steps.cy.tsx @@ -6,42 +6,56 @@ import { splitDescriptionByTechSteps } from "../../src/features/recipes/steps/hi // `expect`, not for rendering. `.cy.tsx` (not `.cy.ts`) only because that's // what `cypress.config.ts`'s component `specPattern` looks for. -function techStep(key: string, id: number, start: number, end: number): StepTechStepView { - return { techStep: { id, key }, start, end }; +/** Builds a `StepTechStepView` — `context` omitted entirely (not just undefined) when absent, matching what the API actually sends for an older, not-yet-recomputed match (see `StepTechStepView`'s own doc comment). */ +function techStep( + key: string, + id: number, + start: number, + end: number, + context?: { start: number; end: number }, +): StepTechStepView { + return { + techStep: { id, key }, + start, + end, + ...(context ? { contextStart: context.start, contextEnd: context.end } : {}), + }; } describe("splitDescriptionByTechSteps", () => { it("returns the whole description as one plain segment when there are no matches", () => { expect(splitDescriptionByTechSteps("Servir immédiatement", [])).to.deep.equal([ - { text: "Servir immédiatement", techStep: null }, + { text: "Servir immédiatement", techStep: null, isKeyword: false }, ]); }); - it("splits a single match into before/match/after segments", () => { - // "Faire mijoter à feu doux" — "mijoter" is [6, 13). + it("splits a single keyword-only match (no context) into before/match/after segments", () => { + // "Faire mijoter à feu doux" — "mijoter" is [6, 13). Same shape as + // before context spans existed at all — the common case for a short, + // already-imperative clause where the keyword and its context coincide. const result = splitDescriptionByTechSteps("Faire mijoter à feu doux", [ techStep("simmer", 1, 6, 13), ]); expect(result).to.deep.equal([ - { text: "Faire ", techStep: null }, - { text: "mijoter", techStep: { id: 1, key: "simmer" } }, - { text: " à feu doux", techStep: null }, + { text: "Faire ", techStep: null, isKeyword: false }, + { text: "mijoter", techStep: { id: 1, key: "simmer" }, isKeyword: true }, + { text: " à feu doux", techStep: null, isKeyword: false }, ]); }); it("handles a match at the very start, with nothing before it", () => { const result = splitDescriptionByTechSteps("Hacher les oignons", [techStep("chop", 2, 0, 6)]); expect(result).to.deep.equal([ - { text: "Hacher", techStep: { id: 2, key: "chop" } }, - { text: " les oignons", techStep: null }, + { text: "Hacher", techStep: { id: 2, key: "chop" }, isKeyword: true }, + { text: " les oignons", techStep: null, isKeyword: false }, ]); }); it("handles a match at the very end, with nothing after it", () => { const result = splitDescriptionByTechSteps("Faire cuire", [techStep("cook", 3, 6, 11)]); expect(result).to.deep.equal([ - { text: "Faire ", techStep: null }, - { text: "cuire", techStep: { id: 3, key: "cook" } }, + { text: "Faire ", techStep: null, isKeyword: false }, + { text: "cuire", techStep: { id: 3, key: "cook" }, isKeyword: true }, ]); }); @@ -53,34 +67,38 @@ describe("splitDescriptionByTechSteps", () => { ]); expect(result.map((s) => s.text).join("")).to.equal(text); expect(result.filter((s) => s.techStep !== null)).to.have.length(2); - expect(result[0]).to.deep.equal({ text: "Préchauffer", techStep: { id: 4, key: "preheat" } }); + expect(result[0]).to.deep.equal({ + text: "Préchauffer", + techStep: { id: 4, key: "preheat" }, + isKeyword: true, + }); }); it("re-sorts entries that aren't already in start order", () => { const text = "Faire fondre le beurre puis préchauffer le four"; // Passed in techStepId order, not text order — the function must sort - // by `start`, not trust the input order. + // by position, not trust the input order. const result = splitDescriptionByTechSteps(text, [ techStep("preheat", 4, 28, 39), techStep("melt", 5, 0, 12), ]); - const matches = result.filter((s) => s.techStep !== null); + const matches = result.filter((s) => s.techStep !== null && s.isKeyword); expect(matches.map((s) => s.techStep?.key)).to.deep.equal(["melt", "preheat"]); }); it("drops a match whose end is past the end of the description", () => { const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 0, 999)]); - expect(result).to.deep.equal([{ text: "Cuire", techStep: null }]); + expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]); }); it("drops a match with a negative start", () => { const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, -1, 5)]); - expect(result).to.deep.equal([{ text: "Cuire", techStep: null }]); + expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]); }); it("drops a match whose start isn't before its end", () => { const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 3, 3)]); - expect(result).to.deep.equal([{ text: "Cuire", techStep: null }]); + expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]); }); it("drops a later match that overlaps one already accepted", () => { @@ -91,10 +109,69 @@ describe("splitDescriptionByTechSteps", () => { techStep("bake", 3, 0, 13), techStep("cook", 2, 0, 5), ]); - expect(result).to.deep.equal([{ text: "Cuire au four", techStep: { id: 3, key: "bake" } }]); + expect(result).to.deep.equal([ + { text: "Cuire au four", techStep: { id: 3, key: "bake" }, isKeyword: true }, + ]); }); it("returns a single empty-ish segment for an empty description with no matches", () => { expect(splitDescriptionByTechSteps("", [])).to.deep.equal([]); }); + + describe("with a context span wider than the keyword", () => { + it("splits into context-before / keyword / context-after around a keyword in the middle of its clause", () => { + // The motivating example: "Dans une poêle chaude, faire chauffer une + // noix de beurre" — `preheat`'s keyword is "poêle chaude", its + // context is the whole "Dans une poêle chaude" clause around it. + const text = "Dans une poêle chaude, faire chauffer une noix de beurre"; + const result = splitDescriptionByTechSteps(text, [ + techStep("preheat", 4, 9, 21, { start: 0, end: 21 }), + ]); + expect(result).to.deep.equal([ + { text: "Dans une ", techStep: { id: 4, key: "preheat" }, isKeyword: false }, + { text: "poêle chaude", techStep: { id: 4, key: "preheat" }, isKeyword: true }, + { + text: ", faire chauffer une noix de beurre", + techStep: null, + isKeyword: false, + }, + ]); + }); + + it("omits the context-before segment when the keyword starts right at the context's own start", () => { + const result = splitDescriptionByTechSteps("préchauffer le four", [ + techStep("preheat", 4, 0, 11, { start: 0, end: 19 }), + ]); + expect(result).to.deep.equal([ + { text: "préchauffer", techStep: { id: 4, key: "preheat" }, isKeyword: true }, + { text: " le four", techStep: { id: 4, key: "preheat" }, isKeyword: false }, + ]); + }); + + it("omits the context-after segment when the keyword ends right at the context's own end", () => { + const result = splitDescriptionByTechSteps("mettre le four à préchauffer", [ + techStep("preheat", 4, 17, 28, { start: 7, end: 28 }), + ]); + expect(result).to.deep.equal([ + { text: "mettre ", techStep: null, isKeyword: false }, + { text: "le four à ", techStep: { id: 4, key: "preheat" }, isKeyword: false }, + { text: "préchauffer", techStep: { id: 4, key: "preheat" }, isKeyword: true }, + ]); + }); + + it("falls back to a keyword-only segment when context is absent (an older, not-yet-recomputed match)", () => { + const result = splitDescriptionByTechSteps("Faire mijoter à feu doux", [ + techStep("simmer", 1, 6, 13), + ]); + expect(result.some((s) => s.techStep !== null && !s.isKeyword)).to.equal(false); + }); + + it("drops an entry whose context doesn't actually contain its own keyword span", () => { + const result = splitDescriptionByTechSteps("Cuire au four", [ + // contextEnd (5) is before the keyword's own end (13) — malformed. + techStep("bake", 3, 0, 13, { start: 0, end: 5 }), + ]); + expect(result).to.deep.equal([{ text: "Cuire au four", techStep: null, isKeyword: false }]); + }); + }); }); diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index 26d4e96..a21fefa 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -627,6 +627,21 @@ } } +// The wider clause a `.step-tech-step` keyword was found in (see +// StepDescription.tsx/highlight-tech-steps.ts's `contextStart`/ +// `contextEnd`) — a much lighter tint than the keyword itself (no +// underline, no hover/focus state: purely visual, not interactive, the +// keyword segment inside/beside it already carries the tooltip) so the +// keyword still reads as the strongest highlight, this just shows how much +// of the sentence it was understood from. +.step-tech-step-context { + display: inline; + padding: 0 0.15em; + margin: 0; + border-radius: 0.2em; + background: color-mix(in srgb, var(--color-primary) 6%, transparent); +} + // --- Favorite star toggle (detail panel header) ----------------------------- .favorite-star-button { position: absolute; diff --git a/apps/web/src/features/recipes/steps/StepDescription.tsx b/apps/web/src/features/recipes/steps/StepDescription.tsx index cbbd317..d7f3a77 100644 --- a/apps/web/src/features/recipes/steps/StepDescription.tsx +++ b/apps/web/src/features/recipes/steps/StepDescription.tsx @@ -9,6 +9,11 @@ import { splitDescriptionByTechSteps } from "./highlight-tech-steps"; * matched words highlighted and given a {@link Tooltip} naming the * technique (e.g. hovering/focusing "hacher" in "Hacher les oignons" shows * "Hacher") — `RecipeDetailPanel`'s replacement for a bare `

{description}

`. + * When a match also carries `contextStart`/`contextEnd` (see + * `StepTechStepView`), the wider clause the keyword was found in (e.g. + * "Dans une poêle chaude" around a `preheat` keyword of "poêle chaude") is + * highlighted too, more subtly — no tooltip of its own, the keyword inside + * it already carries one. * * `techStep.key` resolves its tooltip label through `catalog.techSteps.` * i18n, the same pattern every other reference catalog (diets, units, …) @@ -34,6 +39,17 @@ export function StepDescription({ // spliced in place), so using it as part of the key is safe here. const key = `${index}-${segment.text}`; if (!segment.techStep) return {segment.text}; + + if (!segment.isKeyword) { + // Context-only run — subtly highlighted, no tooltip of its own + // (the keyword segment elsewhere in this same technique already + // has one) and not interactive, unlike the keyword's