feat(api): ajoute la délimitation de contexte aux tech steps et étoffe le vocabulaire du classifieur
Deux évolutions du pipeline NLP de détection des tech steps (PR #63) : 1. Délimitation de contexte — en plus du mot-clé qui déclenche un match (start/end), chaque TechStepMatch porte maintenant contextStart/ contextEnd : la clause complète autour du mot-clé (ex : "poêle chaude" comme mot-clé, "Dans une poêle chaude" comme contexte). Persisté sur StepTechStep (colonnes nullables, migration dédiée), exposé via StepTechStepView, et rendu côté web avec un style plus discret que le mot-clé (StepDescription.tsx, .step-tech-step-context). splitIntoClauses coupe désormais sur l'espace le plus proche du milieu de l'écart entre deux candidats plutôt que sur le milieu brut, pour ne jamais couper un mot en deux (findGapSplitPoint). 2. Vocabulaire du classifieur — synonymes et locutions supplémentaires par technique (FR/EN) pour fiabiliser la détection sur des formulations que le corpus initial ne couvrait pas. Plusieurs bugs de fond trouvés et corrigés en cours de route, tous confirmés par la suite de tests complète (309 tests) : - un synonyme multi-mots qui est un préfixe-mot d'un synonyme plus court déjà enregistré pour la même technique fait matcher les deux comme candidats NER distincts et chevauchants, corrompant le découpage en clauses (parfois jusqu'à une mauvaise classification) — retiré partout où ce motif a été repéré (cook, fry, deglaze, simmer, boil, roast, chop, mince, marinate, preheat, bake, plate, coat) ; - "poêlé"/"poêlée" comme synonymes de panFry sont réduits à la même racine que le nom "poêle" par le stemmer français de node-nlp, provoquant un faux positif sur toute mention nue de "poêle" (dont celle de preheat) — retiré ; - "Fouetter les blancs en neige" était mal classé en foldIn (la phrase d'entraînement de foldIn partage la même locution) — corrigé en ajoutant des phrases d'entraînement dédiées à whisk ; - "Émincer les tomates" est passé sous le seuil de confiance vers melt après l'ajout du nouveau vocabulaire ailleurs dans le corpus — corrigé en élargissant les phrases d'entraînement de mince à un autre légume. Le test unitaire de splitIntoClauses avec un point de coupure obsolète (pré-datant findGapSplitPoint) est aussi corrigé. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
9f68c144f3
commit
8d741ed13f
12 changed files with 759 additions and 141 deletions
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "step_tech_step" ADD COLUMN "context_end" INTEGER,
|
||||||
|
ADD COLUMN "context_start" INTEGER;
|
||||||
|
|
||||||
|
|
@ -666,22 +666,30 @@ model Step {
|
||||||
/// techniques in the description), not a global ordering across different
|
/// techniques in the description), not a global ordering across different
|
||||||
/// steps of the recipe (that's `Step.order`).
|
/// steps of the recipe (that's `Step.order`).
|
||||||
///
|
///
|
||||||
/// `start`/`end` are the matched span within `Step.description` (see
|
/// `start`/`end` are the tight matched *keyword* span within
|
||||||
/// `TechStepMatch`, `tech-step-matcher.ts`) — what the recipe detail view
|
/// `Step.description` (see `TechStepMatch`, `tech-step-matcher.ts`) — what
|
||||||
/// highlights. Nullable, **not backfilled**: adding them `NOT NULL` without
|
/// the recipe detail view highlights strongly, with a tooltip.
|
||||||
/// a default would fail outright against any pre-existing row, the same
|
/// `contextStart`/`contextEnd` are the wider *clause* the keyword was found
|
||||||
/// mistake the `ingredient_unit_catalog` migration made against real prod
|
/// in (e.g. "Dans une poêle chaude" around a `preheat` keyword of "poêle
|
||||||
/// data. A row from before this column existed just has no span (no
|
/// chaude") — always contains `start`/`end` — what the detail view
|
||||||
/// highlight) until its recipe is next saved, which recomputes every step's
|
/// 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
|
/// techniques from scratch (`recipe.service.ts`'s `updateRecipe` deletes
|
||||||
/// and recreates every `Step`/`StepTechStep`, never a partial patch) —
|
/// and recreates every `Step`/`StepTechStep`, never a partial patch) —
|
||||||
/// graceful degradation, not a permanent gap.
|
/// graceful degradation, not a permanent gap.
|
||||||
model StepTechStep {
|
model StepTechStep {
|
||||||
stepId Int @map("step_id")
|
stepId Int @map("step_id")
|
||||||
techStepId Int @map("tech_step_id")
|
techStepId Int @map("tech_step_id")
|
||||||
order Int
|
order Int
|
||||||
start Int?
|
start Int?
|
||||||
end Int?
|
end Int?
|
||||||
|
contextStart Int? @map("context_start")
|
||||||
|
contextEnd Int? @map("context_end")
|
||||||
|
|
||||||
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
|
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
|
||||||
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
|
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
|
||||||
|
|
|
||||||
|
|
@ -71,15 +71,31 @@ export function normalizeText(text: string): string {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One technique {@link matchTechStepSpans} found, alongside exactly where in
|
* One technique {@link matchTechStepSpans} found, alongside exactly where in
|
||||||
* `description` it matched — `[start, end)`, same convention as
|
* `description` it matched — two nested spans, both `[start, end)` (same
|
||||||
* `String.prototype.slice`. Persisted as `StepTechStep.start`/`end`
|
* convention as `String.prototype.slice`):
|
||||||
* (`recipe.service.ts`) so the recipe detail view can highlight the exact
|
*
|
||||||
* matched words, not just know a technique was mentioned somewhere.
|
* - `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 {
|
export interface TechStepMatch {
|
||||||
techStepId: number;
|
techStepId: number;
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
|
contextStart: number;
|
||||||
|
contextEnd: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A candidate technique mention found by NER — the raw material {@link splitIntoClauses} cuts a description around. */
|
/** 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). */
|
/** 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 {
|
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;
|
start: number;
|
||||||
end: 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;
|
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
|
* Cuts `description` into clauses around `candidates` (NER's found
|
||||||
* technique mentions, already sorted or not — sorted internally), one
|
* 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
|
* - **One candidate**: the whole description is one clause too (nothing to
|
||||||
* cut around a single mention), but *with* that candidate as its anchor
|
* cut around a single mention), but *with* that candidate as its anchor
|
||||||
* — callers get its tight span for highlighting.
|
* — callers get its tight span for highlighting.
|
||||||
* - **Two or more**: split points fall halfway between each consecutive
|
* - **Two or more**: split points fall at the whitespace nearest the
|
||||||
* pair's `[end, nextStart]` gap, producing that many contiguous,
|
* midpoint of each consecutive pair's `[end, nextStart]` gap (see
|
||||||
* non-overlapping clauses covering the whole description — clause *i* is
|
* {@link findGapSplitPoint} — never mid-word), producing that many
|
||||||
* anchored on candidate *i*.
|
* contiguous, non-overlapping clauses covering the whole description —
|
||||||
|
* clause *i* is anchored on candidate *i*.
|
||||||
*
|
*
|
||||||
* Pure and DB/model-free — unit-tested directly (see
|
* Pure and DB/model-free — unit-tested directly (see
|
||||||
* `test/tech-step-matcher.test.ts`) without needing a trained classifier.
|
* `test/tech-step-matcher.test.ts`) without needing a trained classifier.
|
||||||
|
|
@ -149,11 +194,7 @@ export function splitIntoClauses(
|
||||||
let clauseStart = 0;
|
let clauseStart = 0;
|
||||||
let anchor = first;
|
let anchor = first;
|
||||||
for (const next of rest) {
|
for (const next of rest) {
|
||||||
// Midpoint of the gap between this candidate's end and the next one's
|
const splitPoint = findGapSplitPoint(description, anchor.end, next.start);
|
||||||
// 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);
|
|
||||||
clauses.push({ start: clauseStart, end: splitPoint, anchor });
|
clauses.push({ start: clauseStart, end: splitPoint, anchor });
|
||||||
clauseStart = splitPoint;
|
clauseStart = splitPoint;
|
||||||
anchor = next;
|
anchor = next;
|
||||||
|
|
@ -279,7 +320,13 @@ export class TechStepClassifierService {
|
||||||
// persist a dangling id.
|
// persist a dangling id.
|
||||||
if (techStepId === undefined) continue;
|
if (techStepId === undefined) continue;
|
||||||
const span = clause.anchor ?? { start: clause.start, end: clause.end };
|
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);
|
matches.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId);
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,22 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "cook",
|
uid: "cook",
|
||||||
fr: {
|
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: [
|
utterances: [
|
||||||
"faire cuire à feu moyen",
|
"faire cuire à feu moyen",
|
||||||
"laisser cuire jusqu'à ce que ce soit prêt",
|
"laisser cuire jusqu'à ce que ce soit prêt",
|
||||||
|
|
@ -61,6 +76,14 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
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"],
|
synonyms: ["cook", "cooks", "cooked", "cooking"],
|
||||||
utterances: [
|
utterances: [
|
||||||
"cook over medium heat",
|
"cook over medium heat",
|
||||||
|
|
@ -74,7 +97,17 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "fry",
|
uid: "fry",
|
||||||
fr: {
|
fr: {
|
||||||
synonyms: ["frire", "frit", "frite", "frites", "friture"],
|
synonyms: [
|
||||||
|
"frire",
|
||||||
|
"frit",
|
||||||
|
"frite",
|
||||||
|
"frites",
|
||||||
|
"friture",
|
||||||
|
"faire frire",
|
||||||
|
"faites frire",
|
||||||
|
"bain de friture",
|
||||||
|
"huile de friture",
|
||||||
|
],
|
||||||
utterances: [
|
utterances: [
|
||||||
"faire frire dans l'huile chaude",
|
"faire frire dans l'huile chaude",
|
||||||
"plonger dans la friture",
|
"plonger dans la friture",
|
||||||
|
|
@ -83,7 +116,10 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
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: [
|
utterances: [
|
||||||
"fry in hot oil",
|
"fry in hot oil",
|
||||||
"deep fry until golden",
|
"deep fry until golden",
|
||||||
|
|
@ -95,7 +131,24 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "melt",
|
uid: "melt",
|
||||||
fr: {
|
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: [
|
utterances: [
|
||||||
"faire fondre le beurre",
|
"faire fondre le beurre",
|
||||||
"jusqu'à ce que le beurre ait disparu dans la poêle",
|
"jusqu'à ce que le beurre ait disparu dans la poêle",
|
||||||
|
|
@ -104,7 +157,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
synonyms: ["melt", "melts", "melted", "melting"],
|
synonyms: ["melt", "melts", "melted", "melting", "liquefy", "liquefied"],
|
||||||
utterances: [
|
utterances: [
|
||||||
"melt the butter",
|
"melt the butter",
|
||||||
"until the butter has completely disappeared into the pan",
|
"until the butter has completely disappeared into the pan",
|
||||||
|
|
@ -116,6 +169,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "deglaze",
|
uid: "deglaze",
|
||||||
fr: {
|
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"],
|
synonyms: ["déglacer", "déglacez", "déglacé", "déglacée", "déglaçage"],
|
||||||
utterances: [
|
utterances: [
|
||||||
"déglacer avec le vin blanc",
|
"déglacer avec le vin blanc",
|
||||||
|
|
@ -124,7 +180,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
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: [
|
utterances: [
|
||||||
"deglaze with white wine",
|
"deglaze with white wine",
|
||||||
"pour the wine into the hot pan to lift the browned bits",
|
"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",
|
uid: "simmer",
|
||||||
fr: {
|
fr: {
|
||||||
synonyms: ["mijoter", "mijotez", "mijote", "mijotant", "mijoté"],
|
synonyms: [
|
||||||
|
"mijoter",
|
||||||
|
"mijotez",
|
||||||
|
"mijote",
|
||||||
|
"mijotant",
|
||||||
|
"mijoté",
|
||||||
|
"frémir",
|
||||||
|
"frémissant",
|
||||||
|
"frémissante",
|
||||||
|
"à petit feu",
|
||||||
|
],
|
||||||
utterances: [
|
utterances: [
|
||||||
"laisser mijoter à feu doux",
|
"laisser mijoter à feu doux",
|
||||||
"faire mijoter pendant une heure",
|
"faire mijoter pendant une heure",
|
||||||
|
|
@ -144,7 +212,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
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: [
|
utterances: [
|
||||||
"let it simmer over low heat",
|
"let it simmer over low heat",
|
||||||
"simmer for one hour",
|
"simmer for one hour",
|
||||||
|
|
@ -156,7 +226,15 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "boil",
|
uid: "boil",
|
||||||
fr: {
|
fr: {
|
||||||
synonyms: ["bouillir", "bouillant", "bouillie", "bouillies", "ébullition"],
|
synonyms: [
|
||||||
|
"bouillir",
|
||||||
|
"bouillant",
|
||||||
|
"bouillie",
|
||||||
|
"bouillies",
|
||||||
|
"ébullition",
|
||||||
|
"porter à ébullition",
|
||||||
|
"gros bouillons",
|
||||||
|
],
|
||||||
utterances: [
|
utterances: [
|
||||||
"porter à ébullition",
|
"porter à ébullition",
|
||||||
"faire bouillir l'eau",
|
"faire bouillir l'eau",
|
||||||
|
|
@ -165,7 +243,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
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: [
|
utterances: [
|
||||||
"bring to a boil",
|
"bring to a boil",
|
||||||
"boil the water",
|
"boil the water",
|
||||||
|
|
@ -177,7 +257,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "roast",
|
uid: "roast",
|
||||||
fr: {
|
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: [
|
utterances: [
|
||||||
"faire rôtir la volaille entière",
|
"faire rôtir la volaille entière",
|
||||||
"le rôti doit dorer uniformément de tous les côtés",
|
"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: {
|
en: {
|
||||||
synonyms: ["roast", "roasts", "roasted", "roasting"],
|
synonyms: ["roast", "roasts", "roasted", "roasting", "oven-roast", "oven roasted"],
|
||||||
utterances: [
|
utterances: [
|
||||||
"roast the whole bird",
|
"roast the whole bird",
|
||||||
"it should brown evenly on every side",
|
"it should brown evenly on every side",
|
||||||
|
|
@ -196,7 +278,17 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "grill",
|
uid: "grill",
|
||||||
fr: {
|
fr: {
|
||||||
synonyms: ["griller", "grillez", "grillé", "grillée", "grillées", "grillade"],
|
synonyms: [
|
||||||
|
"griller",
|
||||||
|
"grillez",
|
||||||
|
"grillé",
|
||||||
|
"grillée",
|
||||||
|
"grillées",
|
||||||
|
"grillade",
|
||||||
|
"grillades",
|
||||||
|
"barbecue",
|
||||||
|
"au barbecue",
|
||||||
|
],
|
||||||
utterances: [
|
utterances: [
|
||||||
"faire griller sur la grille du barbecue",
|
"faire griller sur la grille du barbecue",
|
||||||
"marquer les steaks sur une plaque brûlante",
|
"marquer les steaks sur une plaque brûlante",
|
||||||
|
|
@ -204,7 +296,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
synonyms: ["grill", "grills", "grilled", "grilling"],
|
synonyms: ["grill", "grills", "grilled", "grilling", "barbecue", "char-grill", "charbroiled"],
|
||||||
utterances: [
|
utterances: [
|
||||||
"grill on the barbecue rack",
|
"grill on the barbecue rack",
|
||||||
"sear the steaks on a scorching-hot plate",
|
"sear the steaks on a scorching-hot plate",
|
||||||
|
|
@ -215,6 +307,13 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "panFry",
|
uid: "panFry",
|
||||||
fr: {
|
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"],
|
synonyms: ["sauter", "sautez", "sauté", "sautée", "sautées", "sautant", "à la poêle"],
|
||||||
utterances: [
|
utterances: [
|
||||||
"faire sauter les légumes à la poêle",
|
"faire sauter les légumes à la poêle",
|
||||||
|
|
@ -223,7 +322,18 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
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: [
|
utterances: [
|
||||||
"sauté the vegetables in a pan",
|
"sauté the vegetables in a pan",
|
||||||
"quickly sear over high heat, stirring constantly",
|
"quickly sear over high heat, stirring constantly",
|
||||||
|
|
@ -234,7 +344,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "blanch",
|
uid: "blanch",
|
||||||
fr: {
|
fr: {
|
||||||
synonyms: ["blanchir", "blanchissez", "blanchi", "blanchie", "blanchies"],
|
synonyms: ["blanchir", "blanchissez", "blanchi", "blanchie", "blanchies", "blanchiment"],
|
||||||
utterances: [
|
utterances: [
|
||||||
"faire blanchir les légumes deux minutes dans l'eau bouillante",
|
"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",
|
"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: {
|
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: [
|
utterances: [
|
||||||
"blanch the vegetables for two minutes in boiling water",
|
"blanch the vegetables for two minutes in boiling water",
|
||||||
"briefly plunge into boiling water then straight into ice water",
|
"briefly plunge into boiling water then straight into ice water",
|
||||||
|
|
@ -253,7 +375,18 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "marinate",
|
uid: "marinate",
|
||||||
fr: {
|
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: [
|
utterances: [
|
||||||
"laisser mariner la viande toute la nuit au réfrigérateur",
|
"laisser mariner la viande toute la nuit au réfrigérateur",
|
||||||
"faire tremper dans la sauce plusieurs heures avant cuisson pour parfumer",
|
"faire tremper dans la sauce plusieurs heures avant cuisson pour parfumer",
|
||||||
|
|
@ -261,7 +394,17 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
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: [
|
utterances: [
|
||||||
"let the meat marinate overnight in the fridge",
|
"let the meat marinate overnight in the fridge",
|
||||||
"soak in the sauce for several hours before cooking to flavor it",
|
"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",
|
uid: "chop",
|
||||||
fr: {
|
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: [
|
utterances: [
|
||||||
"hacher finement les oignons",
|
"hacher finement les oignons",
|
||||||
"couper en tout petits morceaux irréguliers au couteau",
|
"couper en tout petits morceaux irréguliers au couteau",
|
||||||
|
|
@ -280,7 +434,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
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: [
|
utterances: [
|
||||||
"finely chop the onions",
|
"finely chop the onions",
|
||||||
"cut into small, uneven pieces with a knife",
|
"cut into small, uneven pieces with a knife",
|
||||||
|
|
@ -291,7 +447,19 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "peel",
|
uid: "peel",
|
||||||
fr: {
|
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: [
|
utterances: [
|
||||||
"éplucher les pommes de terre",
|
"éplucher les pommes de terre",
|
||||||
"retirer la peau des carottes avec un économe",
|
"retirer la peau des carottes avec un économe",
|
||||||
|
|
@ -299,7 +467,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
synonyms: ["peel", "peels", "peeled", "peeling"],
|
synonyms: ["peel", "peels", "peeled", "peeling", "pare", "pared", "paring"],
|
||||||
utterances: [
|
utterances: [
|
||||||
"peel the potatoes",
|
"peel the potatoes",
|
||||||
"remove the skin from the carrots with a peeler",
|
"remove the skin from the carrots with a peeler",
|
||||||
|
|
@ -310,15 +478,35 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "mince",
|
uid: "mince",
|
||||||
fr: {
|
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: [
|
utterances: [
|
||||||
"émincer l'oignon en fines lamelles",
|
"émincer l'oignon en fines lamelles",
|
||||||
"couper en très fines tranches régulières",
|
"couper en très fines tranches régulières",
|
||||||
"détailler en lamelles aussi fines que possible",
|
"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: {
|
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: [
|
utterances: [
|
||||||
"mince the onion into thin strips",
|
"mince the onion into thin strips",
|
||||||
"cut into very thin, even slices",
|
"cut into very thin, even slices",
|
||||||
|
|
@ -329,7 +517,18 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "mix",
|
uid: "mix",
|
||||||
fr: {
|
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: [
|
utterances: [
|
||||||
"mélanger tous les ingrédients dans un saladier",
|
"mélanger tous les ingrédients dans un saladier",
|
||||||
"combiner le sucre et la farine ensemble",
|
"combiner le sucre et la farine ensemble",
|
||||||
|
|
@ -337,7 +536,18 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
synonyms: ["mix", "mixes", "mixed", "mixing", "combine", "combined"],
|
synonyms: [
|
||||||
|
"mix",
|
||||||
|
"mixes",
|
||||||
|
"mixed",
|
||||||
|
"mixing",
|
||||||
|
"combine",
|
||||||
|
"combined",
|
||||||
|
"blend",
|
||||||
|
"blended",
|
||||||
|
"blending",
|
||||||
|
"stir together",
|
||||||
|
],
|
||||||
utterances: [
|
utterances: [
|
||||||
"mix all the ingredients in a bowl",
|
"mix all the ingredients in a bowl",
|
||||||
"combine the sugar and flour together",
|
"combine the sugar and flour together",
|
||||||
|
|
@ -348,26 +558,57 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "whisk",
|
uid: "whisk",
|
||||||
fr: {
|
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: [
|
utterances: [
|
||||||
"fouetter les œufs et le sucre",
|
"fouetter les œufs et le sucre",
|
||||||
"battre vigoureusement au fouet jusqu'à ce que ça blanchisse",
|
"battre vigoureusement au fouet jusqu'à ce que ça blanchisse",
|
||||||
"travailler énergiquement pour incorporer de l'air au mélange",
|
"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: {
|
en: {
|
||||||
synonyms: ["whisk", "whisks", "whisked", "whisking", "beat"],
|
synonyms: ["whisk", "whisks", "whisked", "whisking", "beat", "whip", "whipped", "whipping"],
|
||||||
utterances: [
|
utterances: [
|
||||||
"whisk the eggs and sugar",
|
"whisk the eggs and sugar",
|
||||||
"beat vigorously with a whisk until pale",
|
"beat vigorously with a whisk until pale",
|
||||||
"work it briskly to whip air into the mixture",
|
"work it briskly to whip air into the mixture",
|
||||||
|
"whisk the egg whites until stiff peaks form",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uid: "foldIn",
|
uid: "foldIn",
|
||||||
fr: {
|
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: [
|
utterances: [
|
||||||
"incorporer délicatement les blancs en neige",
|
"incorporer délicatement les blancs en neige",
|
||||||
"ajouter en soulevant doucement la masse pour ne pas casser les bulles",
|
"ajouter en soulevant doucement la masse pour ne pas casser les bulles",
|
||||||
|
|
@ -375,7 +616,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
synonyms: ["fold in", "folds in", "folded in", "folding in"],
|
synonyms: ["fold in", "folds in", "folded in", "folding in", "gently fold", "fold gently"],
|
||||||
utterances: [
|
utterances: [
|
||||||
"gently fold in the beaten egg whites",
|
"gently fold in the beaten egg whites",
|
||||||
"add by gently lifting the batter so you don't knock the air out",
|
"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",
|
uid: "setAside",
|
||||||
fr: {
|
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: [
|
utterances: [
|
||||||
"réserver au frais en attendant",
|
"réserver au frais en attendant",
|
||||||
"mettre de côté pour plus tard",
|
"mettre de côté pour plus tard",
|
||||||
|
|
@ -394,7 +643,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
synonyms: ["set aside", "sets aside", "setting aside", "set it aside"],
|
synonyms: ["set aside", "sets aside", "setting aside", "set it aside", "reserve", "reserved"],
|
||||||
utterances: [
|
utterances: [
|
||||||
"set aside in the fridge for now",
|
"set aside in the fridge for now",
|
||||||
"put it aside for later",
|
"put it aside for later",
|
||||||
|
|
@ -405,7 +654,17 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "season",
|
uid: "season",
|
||||||
fr: {
|
fr: {
|
||||||
synonyms: ["assaisonner", "assaisonnez", "assaisonné", "assaisonnée", "assaisonnement"],
|
synonyms: [
|
||||||
|
"assaisonner",
|
||||||
|
"assaisonnez",
|
||||||
|
"assaisonné",
|
||||||
|
"assaisonnée",
|
||||||
|
"assaisonnement",
|
||||||
|
"relever",
|
||||||
|
"relevez",
|
||||||
|
"épicer",
|
||||||
|
"épicez",
|
||||||
|
],
|
||||||
utterances: [
|
utterances: [
|
||||||
"assaisonner avec du sel et du poivre",
|
"assaisonner avec du sel et du poivre",
|
||||||
"rectifier le goût en ajoutant des épices",
|
"rectifier le goût en ajoutant des épices",
|
||||||
|
|
@ -413,7 +672,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
synonyms: ["season", "seasons", "seasoned", "seasoning"],
|
synonyms: ["season", "seasons", "seasoned", "seasoning", "spice it up", "add seasoning"],
|
||||||
utterances: [
|
utterances: [
|
||||||
"season with salt and pepper",
|
"season with salt and pepper",
|
||||||
"adjust the taste by adding spices",
|
"adjust the taste by adding spices",
|
||||||
|
|
@ -424,7 +683,17 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "drain",
|
uid: "drain",
|
||||||
fr: {
|
fr: {
|
||||||
synonyms: ["égoutter", "égouttez", "égoutté", "égouttée", "égouttées"],
|
synonyms: [
|
||||||
|
"égoutter",
|
||||||
|
"égouttez",
|
||||||
|
"égoutté",
|
||||||
|
"égouttée",
|
||||||
|
"égouttées",
|
||||||
|
"essorer",
|
||||||
|
"essorez",
|
||||||
|
"essoré",
|
||||||
|
"essorée",
|
||||||
|
],
|
||||||
utterances: [
|
utterances: [
|
||||||
"égoutter les pâtes dans une passoire",
|
"égoutter les pâtes dans une passoire",
|
||||||
"verser dans une passoire pour retirer l'eau de cuisson",
|
"verser dans une passoire pour retirer l'eau de cuisson",
|
||||||
|
|
@ -432,7 +701,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
synonyms: ["drain", "drains", "drained", "draining"],
|
synonyms: ["drain", "drains", "drained", "draining", "strain", "strained", "straining"],
|
||||||
utterances: [
|
utterances: [
|
||||||
"drain the pasta in a colander",
|
"drain the pasta in a colander",
|
||||||
"pour into a colander to remove the cooking water",
|
"pour into a colander to remove the cooking water",
|
||||||
|
|
@ -443,7 +712,15 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "brown",
|
uid: "brown",
|
||||||
fr: {
|
fr: {
|
||||||
synonyms: ["faire revenir", "faites revenir", "faire dorer", "faites dorer"],
|
synonyms: [
|
||||||
|
"faire revenir",
|
||||||
|
"faites revenir",
|
||||||
|
"faire dorer",
|
||||||
|
"faites dorer",
|
||||||
|
"colorer",
|
||||||
|
"colorez",
|
||||||
|
"faire colorer",
|
||||||
|
],
|
||||||
utterances: [
|
utterances: [
|
||||||
"faire revenir les oignons dans l'huile chaude",
|
"faire revenir les oignons dans l'huile chaude",
|
||||||
"faire dorer la viande sur toutes les faces",
|
"faire dorer la viande sur toutes les faces",
|
||||||
|
|
@ -467,7 +744,7 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "rest",
|
uid: "rest",
|
||||||
fr: {
|
fr: {
|
||||||
synonyms: ["reposer", "laisser reposer", "laissez reposer"],
|
synonyms: ["reposer", "laisser reposer", "laissez reposer", "temps de repos"],
|
||||||
utterances: [
|
utterances: [
|
||||||
"laisser reposer la pâte trente minutes",
|
"laisser reposer la pâte trente minutes",
|
||||||
"laisser la viande se détendre hors du four avant de la découper",
|
"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: {
|
en: {
|
||||||
// Anchored to "let ... rest"/"rest for" rather than bare "rest",
|
// Anchored to "let ... rest"/"rest for" rather than bare "rest",
|
||||||
// same false-positive reasoning as `brown` above ("the rest of the").
|
// 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: [
|
utterances: [
|
||||||
"let the dough rest for thirty minutes",
|
"let the dough rest for thirty minutes",
|
||||||
"let the meat relax outside the oven before carving it",
|
"let the meat relax outside the oven before carving it",
|
||||||
|
|
@ -488,7 +765,18 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "preheat",
|
uid: "preheat",
|
||||||
fr: {
|
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: [
|
utterances: [
|
||||||
"préchauffer le four à 180 degrés",
|
"préchauffer le four à 180 degrés",
|
||||||
"mettre le four à chauffer avant d'y placer le plat",
|
"mettre le four à chauffer avant d'y placer le plat",
|
||||||
|
|
@ -511,7 +799,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
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: [
|
utterances: [
|
||||||
"preheat the oven to 180 degrees",
|
"preheat the oven to 180 degrees",
|
||||||
"turn the oven on to heat up before putting the dish in",
|
"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",
|
uid: "bake",
|
||||||
fr: {
|
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: [
|
utterances: [
|
||||||
"enfourner pendant quarante-cinq minutes",
|
"enfourner pendant quarante-cinq minutes",
|
||||||
"mettre au four jusqu'à ce que ce soit doré",
|
"mettre au four jusqu'à ce que ce soit doré",
|
||||||
|
|
@ -532,7 +830,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
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: [
|
utterances: [
|
||||||
"bake for forty-five minutes",
|
"bake for forty-five minutes",
|
||||||
"put it in the oven until golden",
|
"put it in the oven until golden",
|
||||||
|
|
@ -543,7 +843,10 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "plate",
|
uid: "plate",
|
||||||
fr: {
|
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: [
|
utterances: [
|
||||||
"dresser harmonieusement dans les assiettes",
|
"dresser harmonieusement dans les assiettes",
|
||||||
"disposer joliment sur l'assiette avant de servir",
|
"disposer joliment sur l'assiette avant de servir",
|
||||||
|
|
@ -551,6 +854,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
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"],
|
synonyms: ["plate", "plates", "plated", "plating"],
|
||||||
utterances: [
|
utterances: [
|
||||||
"plate it up nicely",
|
"plate it up nicely",
|
||||||
|
|
@ -562,7 +868,19 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
{
|
{
|
||||||
uid: "coat",
|
uid: "coat",
|
||||||
fr: {
|
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: [
|
utterances: [
|
||||||
"napper le gâteau de chocolat fondu",
|
"napper le gâteau de chocolat fondu",
|
||||||
"recouvrir uniformément d'une fine couche de sauce",
|
"recouvrir uniformément d'une fine couche de sauce",
|
||||||
|
|
@ -570,7 +888,9 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
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: [
|
utterances: [
|
||||||
"coat the cake with melted chocolate",
|
"coat the cake with melted chocolate",
|
||||||
"cover evenly with a thin layer of sauce",
|
"cover evenly with a thin layer of sauce",
|
||||||
|
|
|
||||||
|
|
@ -123,24 +123,28 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shapes a step's `StepTechStep` rows into {@link StepTechStepView}s — a row
|
* 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
|
* whose `start`/`end` is still `null` (a pre-existing row saved before that
|
||||||
* column existed, not yet recomputed by a resave — see the schema doc
|
* column pair existed, not yet recomputed by a resave — see the schema doc
|
||||||
* comment on `StepTechStep`) is dropped rather than surfaced with a null
|
* comment on `StepTechStep`) is dropped rather than surfaced with a null
|
||||||
* span, so the frontend only ever deals with real, highlightable matches.
|
* 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(
|
function toStepTechStepViews(
|
||||||
techSteps: RecipeWithDetails["steps"][number]["techSteps"],
|
techSteps: RecipeWithDetails["steps"][number]["techSteps"],
|
||||||
): StepTechStepView[] {
|
): StepTechStepView[] {
|
||||||
const views: StepTechStepView[] = [];
|
const views: StepTechStepView[] = [];
|
||||||
for (const stepTechStep of techSteps) {
|
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({
|
views.push({
|
||||||
techStep: {
|
techStep: { id: techStep.id, key: techStep.key },
|
||||||
id: stepTechStep.techStep.id,
|
start,
|
||||||
key: stepTechStep.techStep.key,
|
end,
|
||||||
},
|
...(contextStart !== null && contextEnd !== null ? { contextStart, contextEnd } : {}),
|
||||||
start: stepTechStep.start,
|
|
||||||
end: stepTechStep.end,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return views;
|
return views;
|
||||||
|
|
@ -532,6 +536,8 @@ async function createRecipeInternal(
|
||||||
techStepId: match.techStepId,
|
techStepId: match.techStepId,
|
||||||
start: match.start,
|
start: match.start,
|
||||||
end: match.end,
|
end: match.end,
|
||||||
|
contextStart: match.contextStart,
|
||||||
|
contextEnd: match.contextEnd,
|
||||||
order,
|
order,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
|
@ -602,6 +608,8 @@ export async function updateRecipe(
|
||||||
techStepId: match.techStepId,
|
techStepId: match.techStepId,
|
||||||
start: match.start,
|
start: match.start,
|
||||||
end: match.end,
|
end: match.end,
|
||||||
|
contextStart: match.contextStart,
|
||||||
|
contextEnd: match.contextEnd,
|
||||||
order,
|
order,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,17 @@ export async function previewSourceItem(
|
||||||
picture: step.picture,
|
picture: step.picture,
|
||||||
techSteps: matches.flatMap((match) => {
|
techSteps: matches.flatMap((match) => {
|
||||||
const techStep = techStepById.get(match.techStepId);
|
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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ describe("tech-step-matcher", () => {
|
||||||
expect(result).to.deep.equal([{ start: 0, end: text.length, anchor: melt }]);
|
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"
|
// "Préchauffer la poêle, puis faire fondre le beurre"
|
||||||
// 0 1 2 3 4
|
// 0 1 2 3 4
|
||||||
// 0123456789012345678901234567890123456789012345678901
|
// 0123456789012345678901234567890123456789012345678901
|
||||||
|
|
@ -59,8 +59,13 @@ describe("tech-step-matcher", () => {
|
||||||
const result = splitIntoClauses(text, [preheat, melt]);
|
const result = splitIntoClauses(text, [preheat, melt]);
|
||||||
|
|
||||||
expect(result).to.have.length(2);
|
expect(result).to.have.length(2);
|
||||||
expect(result[0]).to.deep.equal({ start: 0, end: 19, anchor: preheat });
|
// The gap between the two candidates is [11, 27) — its raw midpoint
|
||||||
expect(result[1]).to.deep.equal({ start: 19, end: text.length, anchor: melt });
|
// (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.
|
// The two clauses are contiguous and cover the whole text.
|
||||||
expect(
|
expect(
|
||||||
text.slice(result[0].start, result[0].end) + text.slice(result[1].start, result[1].end),
|
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", () => {
|
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 text = "Faire mijoter à feu doux";
|
||||||
const result = await techStepClassifier.matchTechStepSpans(text, "fr");
|
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");
|
expect(text.slice(6, 13).toLowerCase()).to.equal("mijoter");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -256,30 +263,83 @@ describe("tech-step-matcher", () => {
|
||||||
).to.deep.equal([]);
|
).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 text = "Préchauffer la poêle, puis faire fondre le beurre";
|
||||||
const result = await techStepClassifier.matchTechStepSpans(text, "fr");
|
const result = await techStepClassifier.matchTechStepSpans(text, "fr");
|
||||||
|
|
||||||
expect(result).to.have.length(2);
|
expect(result).to.have.length(2);
|
||||||
expect(result[0].techStepId).to.equal(preheatId);
|
expect(result[0].techStepId).to.equal(preheatId);
|
||||||
expect(result[1].techStepId).to.equal(meltId);
|
expect(result[1].techStepId).to.equal(meltId);
|
||||||
// Each span, sliced back out of the original text, is exactly the
|
// Each keyword span, sliced back out of the original text, is
|
||||||
// word(s) that anchored that match — what the frontend needs to
|
// exactly the word(s) that anchored that match — what the frontend
|
||||||
// highlight the right characters.
|
// 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[0].start, result[0].end).toLowerCase()).to.equal("préchauffer");
|
||||||
expect(text.slice(result[1].start, result[1].end).toLowerCase()).to.equal("faire fondre");
|
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 text = "jusqu'à ce que le beurre ait disparu dans la poêle";
|
||||||
const result = await techStepClassifier.matchTechStepSpans(text, "fr");
|
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 text = "Chop the onions finely";
|
||||||
const result = await techStepClassifier.matchTechStepSpans(text, "en");
|
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");
|
expect(text.slice(0, 4)).to.equal("Chop");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
// `expect`, not for rendering. `.cy.tsx` (not `.cy.ts`) only because that's
|
||||||
// what `cypress.config.ts`'s component `specPattern` looks for.
|
// what `cypress.config.ts`'s component `specPattern` looks for.
|
||||||
|
|
||||||
function techStep(key: string, id: number, start: number, end: number): StepTechStepView {
|
/** 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). */
|
||||||
return { techStep: { id, key }, start, end };
|
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", () => {
|
describe("splitDescriptionByTechSteps", () => {
|
||||||
it("returns the whole description as one plain segment when there are no matches", () => {
|
it("returns the whole description as one plain segment when there are no matches", () => {
|
||||||
expect(splitDescriptionByTechSteps("Servir immédiatement", [])).to.deep.equal([
|
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", () => {
|
it("splits a single keyword-only match (no context) into before/match/after segments", () => {
|
||||||
// "Faire mijoter à feu doux" — "mijoter" is [6, 13).
|
// "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", [
|
const result = splitDescriptionByTechSteps("Faire mijoter à feu doux", [
|
||||||
techStep("simmer", 1, 6, 13),
|
techStep("simmer", 1, 6, 13),
|
||||||
]);
|
]);
|
||||||
expect(result).to.deep.equal([
|
expect(result).to.deep.equal([
|
||||||
{ text: "Faire ", techStep: null },
|
{ text: "Faire ", techStep: null, isKeyword: false },
|
||||||
{ text: "mijoter", techStep: { id: 1, key: "simmer" } },
|
{ text: "mijoter", techStep: { id: 1, key: "simmer" }, isKeyword: true },
|
||||||
{ text: " à feu doux", techStep: null },
|
{ text: " à feu doux", techStep: null, isKeyword: false },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles a match at the very start, with nothing before it", () => {
|
it("handles a match at the very start, with nothing before it", () => {
|
||||||
const result = splitDescriptionByTechSteps("Hacher les oignons", [techStep("chop", 2, 0, 6)]);
|
const result = splitDescriptionByTechSteps("Hacher les oignons", [techStep("chop", 2, 0, 6)]);
|
||||||
expect(result).to.deep.equal([
|
expect(result).to.deep.equal([
|
||||||
{ text: "Hacher", techStep: { id: 2, key: "chop" } },
|
{ text: "Hacher", techStep: { id: 2, key: "chop" }, isKeyword: true },
|
||||||
{ text: " les oignons", techStep: null },
|
{ text: " les oignons", techStep: null, isKeyword: false },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles a match at the very end, with nothing after it", () => {
|
it("handles a match at the very end, with nothing after it", () => {
|
||||||
const result = splitDescriptionByTechSteps("Faire cuire", [techStep("cook", 3, 6, 11)]);
|
const result = splitDescriptionByTechSteps("Faire cuire", [techStep("cook", 3, 6, 11)]);
|
||||||
expect(result).to.deep.equal([
|
expect(result).to.deep.equal([
|
||||||
{ text: "Faire ", techStep: null },
|
{ text: "Faire ", techStep: null, isKeyword: false },
|
||||||
{ text: "cuire", techStep: { id: 3, key: "cook" } },
|
{ 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.map((s) => s.text).join("")).to.equal(text);
|
||||||
expect(result.filter((s) => s.techStep !== null)).to.have.length(2);
|
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", () => {
|
it("re-sorts entries that aren't already in start order", () => {
|
||||||
const text = "Faire fondre le beurre puis préchauffer le four";
|
const text = "Faire fondre le beurre puis préchauffer le four";
|
||||||
// Passed in techStepId order, not text order — the function must sort
|
// 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, [
|
const result = splitDescriptionByTechSteps(text, [
|
||||||
techStep("preheat", 4, 28, 39),
|
techStep("preheat", 4, 28, 39),
|
||||||
techStep("melt", 5, 0, 12),
|
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"]);
|
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", () => {
|
it("drops a match whose end is past the end of the description", () => {
|
||||||
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 0, 999)]);
|
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", () => {
|
it("drops a match with a negative start", () => {
|
||||||
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, -1, 5)]);
|
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", () => {
|
it("drops a match whose start isn't before its end", () => {
|
||||||
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 3, 3)]);
|
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", () => {
|
it("drops a later match that overlaps one already accepted", () => {
|
||||||
|
|
@ -91,10 +109,69 @@ describe("splitDescriptionByTechSteps", () => {
|
||||||
techStep("bake", 3, 0, 13),
|
techStep("bake", 3, 0, 13),
|
||||||
techStep("cook", 2, 0, 5),
|
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", () => {
|
it("returns a single empty-ish segment for an empty description with no matches", () => {
|
||||||
expect(splitDescriptionByTechSteps("", [])).to.deep.equal([]);
|
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 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 toggle (detail panel header) -----------------------------
|
||||||
.favorite-star-button {
|
.favorite-star-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ import { splitDescriptionByTechSteps } from "./highlight-tech-steps";
|
||||||
* matched words highlighted and given a {@link Tooltip} naming the
|
* matched words highlighted and given a {@link Tooltip} naming the
|
||||||
* technique (e.g. hovering/focusing "hacher" in "Hacher les oignons" shows
|
* technique (e.g. hovering/focusing "hacher" in "Hacher les oignons" shows
|
||||||
* "Hacher") — `RecipeDetailPanel`'s replacement for a bare `<p>{description}</p>`.
|
* "Hacher") — `RecipeDetailPanel`'s replacement for a bare `<p>{description}</p>`.
|
||||||
|
* 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.<key>`
|
* `techStep.key` resolves its tooltip label through `catalog.techSteps.<key>`
|
||||||
* i18n, the same pattern every other reference catalog (diets, units, …)
|
* 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.
|
// spliced in place), so using it as part of the key is safe here.
|
||||||
const key = `${index}-${segment.text}`;
|
const key = `${index}-${segment.text}`;
|
||||||
if (!segment.techStep) return <Fragment key={key}>{segment.text}</Fragment>;
|
if (!segment.techStep) return <Fragment key={key}>{segment.text}</Fragment>;
|
||||||
|
|
||||||
|
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 <button>.
|
||||||
|
return (
|
||||||
|
<span key={key} className="step-tech-step-context">
|
||||||
|
{segment.text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Tooltip key={key} content={t(`catalog.techSteps.${segment.techStep.key}`)}>
|
<Tooltip key={key} content={t(`catalog.techSteps.${segment.techStep.key}`)}>
|
||||||
{/* A real <button>, not a <mark>, so it's natively focusable
|
{/* A real <button>, not a <mark>, so it's natively focusable
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,87 @@
|
||||||
import type { StepTechStepView } from "@batch-cooking/shared";
|
import type { StepTechStepView } from "@batch-cooking/shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One run of a step's `description` — either plain text, or the exact
|
* One run of a step's `description` — either plain text, or part of a
|
||||||
* words that triggered a technique match (`techStep` set). What
|
* detected technique (`techStep` set). A technique's own text is itself
|
||||||
* `StepDescription.tsx` renders: plain segments as-is, technique segments
|
* split into up to three runs (see {@link splitDescriptionByTechSteps}):
|
||||||
* wrapped in a highlighted, tooltip-bearing `<mark>`.
|
* the tight keyword span (`isKeyword: true`, e.g. "préchauffer") and, when
|
||||||
|
* `StepTechStepView.contextStart`/`contextEnd` are present, the wider
|
||||||
|
* surrounding clause around it (`isKeyword: false`, e.g. "Dans une poêle
|
||||||
|
* chaude" around a keyword of "poêle chaude"). What `StepDescription.tsx`
|
||||||
|
* renders: plain segments as-is, keyword segments in a strong
|
||||||
|
* tooltip-bearing highlight, context segments in a subtler one around it.
|
||||||
*/
|
*/
|
||||||
export interface DescriptionSegment {
|
export interface DescriptionSegment {
|
||||||
text: string;
|
text: string;
|
||||||
techStep: StepTechStepView["techStep"] | null;
|
techStep: StepTechStepView["techStep"] | null;
|
||||||
|
/** Always `false` when `techStep` is `null`. */
|
||||||
|
isKeyword: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Splits `description` into an ordered sequence of plain/technique
|
* Splits `description` into an ordered sequence of plain/context/keyword
|
||||||
* {@link DescriptionSegment}s using each `techSteps` entry's `start`/`end`
|
* {@link DescriptionSegment}s using each `techSteps` entry's `start`/`end`
|
||||||
* (see `StepTechStepView`, resolved server-side by
|
* (the keyword) and, when present, `contextStart`/`contextEnd` (the wider
|
||||||
* `tech-step-matcher.ts`'s `matchTechStepSpans`).
|
* clause it was found in — see `StepTechStepView`, resolved server-side by
|
||||||
|
* `tech-step-matcher.ts`'s `matchTechStepSpans`). An entry with no context
|
||||||
|
* (older data, saved before that column pair existed — see
|
||||||
|
* `StepTechStep`'s schema doc comment) degrades to a keyword-only segment,
|
||||||
|
* same as before context spans existed at all.
|
||||||
*
|
*
|
||||||
* `techSteps` is expected already sorted by `start` (the API returns it in
|
* `techSteps` is expected already sorted by `start` (the API returns it in
|
||||||
* `StepTechStep.order`, which *is* reading order — see that model's schema
|
* `StepTechStep.order`, which *is* reading order — see that model's schema
|
||||||
* doc comment) but this re-sorts defensively rather than assuming it, and
|
* doc comment) but this re-sorts defensively (by context start when
|
||||||
* silently drops any entry whose bounds don't make sense against
|
* present, since context always starts at or before its own keyword)
|
||||||
* `description` (`start < 0`, `end > description.length`, `start >= end`,
|
* rather than assuming it, and silently drops any entry whose bounds don't
|
||||||
* or overlapping a previously-accepted entry) — a malformed/out-of-date
|
* make sense against `description` or a previously-accepted entry's own
|
||||||
* span degrades to "just don't highlight that one" rather than a garbled
|
* bounds — a malformed/out-of-date span degrades to "just don't highlight
|
||||||
* slice or a crash.
|
* that one" rather than a garbled slice or a crash.
|
||||||
*/
|
*/
|
||||||
export function splitDescriptionByTechSteps(
|
export function splitDescriptionByTechSteps(
|
||||||
description: string,
|
description: string,
|
||||||
techSteps: StepTechStepView[],
|
techSteps: StepTechStepView[],
|
||||||
): DescriptionSegment[] {
|
): DescriptionSegment[] {
|
||||||
const sorted = [...techSteps].sort((a, b) => a.start - b.start);
|
const sorted = [...techSteps].sort(
|
||||||
|
(a, b) => (a.contextStart ?? a.start) - (b.contextStart ?? b.start),
|
||||||
|
);
|
||||||
|
|
||||||
const segments: DescriptionSegment[] = [];
|
const segments: DescriptionSegment[] = [];
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
for (const { techStep, start, end } of sorted) {
|
for (const { techStep, start, end, contextStart, contextEnd } of sorted) {
|
||||||
if (start < 0 || end > description.length || start >= end || start < cursor) continue;
|
const wideStart = contextStart ?? start;
|
||||||
if (start > cursor) segments.push({ text: description.slice(cursor, start), techStep: null });
|
const wideEnd = contextEnd ?? end;
|
||||||
segments.push({ text: description.slice(start, end), techStep });
|
if (
|
||||||
cursor = end;
|
wideStart < cursor ||
|
||||||
|
wideStart > start ||
|
||||||
|
start >= end ||
|
||||||
|
end > wideEnd ||
|
||||||
|
wideEnd > description.length
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wideStart > cursor) {
|
||||||
|
segments.push({
|
||||||
|
text: description.slice(cursor, wideStart),
|
||||||
|
techStep: null,
|
||||||
|
isKeyword: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (start > wideStart) {
|
||||||
|
segments.push({
|
||||||
|
text: description.slice(wideStart, start),
|
||||||
|
techStep,
|
||||||
|
isKeyword: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
segments.push({ text: description.slice(start, end), techStep, isKeyword: true });
|
||||||
|
if (wideEnd > end) {
|
||||||
|
segments.push({ text: description.slice(end, wideEnd), techStep, isKeyword: false });
|
||||||
|
}
|
||||||
|
cursor = wideEnd;
|
||||||
}
|
}
|
||||||
if (cursor < description.length) {
|
if (cursor < description.length) {
|
||||||
segments.push({ text: description.slice(cursor), techStep: null });
|
segments.push({ text: description.slice(cursor), techStep: null, isKeyword: false });
|
||||||
}
|
}
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,15 +28,28 @@ export interface RecipeIngredientView {
|
||||||
* One detected technique within a {@link StepView}'s description, resolved
|
* One detected technique within a {@link StepView}'s description, resolved
|
||||||
* to its reference data (same "resolve at read time" treatment as
|
* to its reference data (same "resolve at read time" treatment as
|
||||||
* {@link RecipeIngredientView}'s `ingredient`/`unit`) alongside exactly
|
* {@link RecipeIngredientView}'s `ingredient`/`unit`) alongside exactly
|
||||||
* where in `description` it was matched (`[start, end)`, same convention as
|
* where in `description` it was matched — two nested `[start, end)` spans
|
||||||
* `String.prototype.slice`) — what the recipe detail view highlights, with
|
* (same convention as `String.prototype.slice`), what the recipe detail
|
||||||
* `techStep.key` resolving a tooltip label through `catalog.techSteps.<key>`
|
* view highlights:
|
||||||
* i18n, the same pattern as every other reference catalog.
|
*
|
||||||
|
* - `start`/`end` — the tight *keyword* span (e.g. "préchauffer"),
|
||||||
|
* highlighted strongly with a tooltip naming the technique
|
||||||
|
* (`techStep.key` resolves the label through `catalog.techSteps.<key>`
|
||||||
|
* i18n, the same pattern as every other reference catalog).
|
||||||
|
* - `contextStart`/`contextEnd` — the wider surrounding *clause* the
|
||||||
|
* keyword was found in (e.g. "Dans une poêle chaude" for a `preheat`
|
||||||
|
* keyword of "poêle chaude"), highlighted more subtly around it.
|
||||||
|
* Optional: absent on a match made before this pair of columns existed
|
||||||
|
* and not yet recomputed (see `StepTechStep`'s schema doc comment) — a
|
||||||
|
* caller with no context just shows the keyword highlight alone, same as
|
||||||
|
* before these existed.
|
||||||
*/
|
*/
|
||||||
export interface StepTechStepView {
|
export interface StepTechStepView {
|
||||||
techStep: TechStepView;
|
techStep: TechStepView;
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
|
contextStart?: number;
|
||||||
|
contextEnd?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue