Compare commits
5 commits
main
...
feat/nlp-t
| Author | SHA1 | Date | |
|---|---|---|---|
| f741bbb4f1 | |||
| c190f3f21c | |||
| 76175bcdbf | |||
| 8d741ed13f | |||
| 9f68c144f3 |
29 changed files with 3057 additions and 865 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -71,6 +71,12 @@ web_modules/
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.test.example
|
!.env.test.example
|
||||||
|
|
||||||
|
# node-nlp's default auto-save file (apps/api/src/lib/recipe-matching/
|
||||||
|
# tech-step-matcher.ts explicitly disables autoSave/autoLoad, but this is a
|
||||||
|
# belt-and-suspenders guard against it ever reappearing — a stale trained
|
||||||
|
# model on disk must never silently shadow TECH_STEP_TRAINING_DATA).
|
||||||
|
model.nlp
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
.cache
|
.cache
|
||||||
.parcel-cache
|
.parcel-cache
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"node-nlp": "4.27.0",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "tech_step_mapping" DROP CONSTRAINT "tech_step_mapping_tech_step_id_fkey";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "tech_step_mapping";
|
||||||
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "step_tech_step" ADD COLUMN "context_end" INTEGER,
|
||||||
|
ADD COLUMN "context_start" INTEGER;
|
||||||
|
|
||||||
|
|
@ -619,36 +619,26 @@ model RecipeIngredient {
|
||||||
/// camelCase uid (e.g. `"simmer"`), not the display label — the French
|
/// camelCase uid (e.g. `"simmer"`), not the display label — the French
|
||||||
/// label lives in `apps/web`'s `locales/fr/translation.json` under
|
/// label lives in `apps/web`'s `locales/fr/translation.json` under
|
||||||
/// `catalog.techSteps.<key>` (see `reference-seed-data.ts`'s `TECH_STEPS`).
|
/// `catalog.techSteps.<key>` (see `reference-seed-data.ts`'s `TECH_STEPS`).
|
||||||
|
///
|
||||||
|
/// Matching a step's free text against these (`tech-step-matcher.ts`'s
|
||||||
|
/// `TechStepClassifierService`) used to go through a DB-backed
|
||||||
|
/// `TechStepMapping` table of per-locale regex expressions — replaced with
|
||||||
|
/// a node-nlp model trained from in-code data
|
||||||
|
/// (`tech-step-training-data.ts`) once regexes turned out unable to
|
||||||
|
/// generalize past their own literal vocabulary. Nothing queries/edits
|
||||||
|
/// that matching data at runtime anymore (it only ever feeds the
|
||||||
|
/// classifier's one-time training pass), so it no longer needs a table of
|
||||||
|
/// its own — this row now only exists to be a stable id/key other tables
|
||||||
|
/// (`StepTechStep`) reference.
|
||||||
model TechStep {
|
model TechStep {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String @unique
|
key String @unique
|
||||||
|
|
||||||
steps StepTechStep[]
|
steps StepTechStep[]
|
||||||
mappings TechStepMapping[]
|
|
||||||
|
|
||||||
@@map("tech_step")
|
@@map("tech_step")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Used by `tech-step-matcher.ts` to auto-detect which technique a recipe
|
|
||||||
/// step's description corresponds to (expression = regex pattern tested
|
|
||||||
/// against the description, weight = tie-break score when several
|
|
||||||
/// mappings match, or overlap-resolution score when two mappings match the
|
|
||||||
/// same span of text — see `matchTechSteps`). `locale` (e.g. `"fr"`) lets
|
|
||||||
/// the same TechStep carry one matching rule set per language — the
|
|
||||||
/// matcher is always called with a target locale and only considers
|
|
||||||
/// mappings for that locale.
|
|
||||||
model TechStepMapping {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
techStepId Int @map("tech_step_id")
|
|
||||||
locale String
|
|
||||||
expression String
|
|
||||||
weight Int
|
|
||||||
|
|
||||||
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@map("tech_step_mapping")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the
|
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the
|
||||||
/// many-to-many noted in the spec doc: `order` only makes sense scoped to a
|
/// many-to-many noted in the spec doc: `order` only makes sense scoped to a
|
||||||
/// single recipe, which isn't reconcilable with steps being shared across
|
/// single recipe, which isn't reconcilable with steps being shared across
|
||||||
|
|
@ -676,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)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,22 @@
|
||||||
|
// Imported for its side effect only (loading `.env`/`.env.test` via
|
||||||
|
// dotenv) — must run *before* `new PrismaClient()` below. The generated
|
||||||
|
// Prisma Client bakes in its own fallback `.env` path (always
|
||||||
|
// `apps/api/.env`, the dev one — resolved once at `prisma generate` time)
|
||||||
|
// and loads it internally the first time a `PrismaClient` is constructed,
|
||||||
|
// unless `DATABASE_URL` is already set in `process.env` by then — dotenv
|
||||||
|
// never overrides an already-set variable, so whichever of these two env
|
||||||
|
// loads runs first "wins" for the rest of the process. Without this
|
||||||
|
// import, that race depended entirely on which test file some *other*
|
||||||
|
// module happened to import first, which normally worked out only by
|
||||||
|
// coincidence (whatever file mocha's `test/**/*.test.ts` glob happens to
|
||||||
|
// resolve first) — running a single test file in isolation (e.g. `mocha
|
||||||
|
// test/some-file.test.ts` directly, bypassing that glob) could silently
|
||||||
|
// resolve `DATABASE_URL` to the real dev database instead of
|
||||||
|
// `.env.test`'s. `resetDatabase()`'s own `assertRunningAgainstTestDatabase`
|
||||||
|
// guard (test-support/reset-db.ts) is what actually caught this in
|
||||||
|
// practice — it throws rather than truncating the wrong database — but
|
||||||
|
// the fix belongs here, at the source, not just at that one call site.
|
||||||
|
import "../config/env.js";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -51,245 +51,49 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }>
|
||||||
{ uid: "pound", type: "MASS", toBaseFactor: 453.5924 },
|
{ uid: "pound", type: "MASS", toBaseFactor: 453.5924 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Cooking-technique catalog (French recipe-step normalization) — a static
|
// Cooking-technique catalog (French recipe-step normalization) — the
|
||||||
// list of common instructions, each carrying one or more text-matching
|
// stable `key`s `tech-step-matcher.ts` auto-detects in a free-text
|
||||||
// rules used by `tech-step-matcher.ts` to auto-detect which technique(s) a
|
// `Step.description` (a step can mention several, e.g. "faire chauffer une
|
||||||
// free-text `Step.description` corresponds to (a step can mention several,
|
// poêle puis y faire fondre le beurre" is both `preheat` and `melt` — see
|
||||||
// e.g. "faire chauffer une poêle puis y faire fondre le beurre" is both
|
// `Step.techSteps`/`StepTechStep` in schema.prisma). Same "English
|
||||||
// `preheat` and `melt` — see `Step.techSteps`/`StepTechStep` in
|
// camelCase uid, no French label" authoring as DIETS/UNITS — the label
|
||||||
// schema.prisma). Same "English camelCase uid, no French label" authoring
|
// lives in apps/web's locales/fr/translation.json under
|
||||||
// as DIETS/UNITS — the label lives in apps/web's
|
// `catalog.techSteps.<key>`.
|
||||||
// locales/fr/translation.json under `catalog.techSteps.<key>`.
|
//
|
||||||
// `expression` is a regex source matched (case/accent-insensitive, via
|
// Just a flat list of stable ids here — the actual matching data (per-
|
||||||
// `normalizeText`) against the step description; `weight` breaks ties when
|
// locale synonym lists + example phrasings the classifier trains on) lives
|
||||||
// two *different* techniques' expressions match the same span of text
|
// in `lib/recipe-matching/tech-step-training-data.ts`'s
|
||||||
// (highest weight wins) — see `tech-step-matcher.ts`'s `matchTechSteps`.
|
// `TECH_STEP_TRAINING_DATA`, not here: unlike this list, it's read by
|
||||||
// Specific, multi-word phrases ("cuire au four", "faire revenir") are
|
// `TechStepClassifierService`'s training pass, not the seed script, so it
|
||||||
// weighted higher than the generic single-verb forms they overlap with
|
// doesn't belong alongside the rest of this file's DB-seeded reference
|
||||||
// ("cuire", "sauter") so the more specific technique wins when both match
|
// data. Every entry here must have a matching entry there.
|
||||||
// the same words. `locale` lets the same technique carry one matching rule
|
export const TECH_STEPS: string[] = [
|
||||||
// set per language — `"fr"` and `"en"` today (the latter mainly for
|
"cook",
|
||||||
// English-language sources like TheMealDB), more can be added later
|
"fry",
|
||||||
// without a schema change. The two locales are independent rule sets, not
|
"melt",
|
||||||
// translations of each other — an English recipe is matched only against
|
"deglaze",
|
||||||
// the `"en"` mappings, never a mix of both.
|
"simmer",
|
||||||
export const TECH_STEPS: Array<{
|
"boil",
|
||||||
uid: string;
|
"roast",
|
||||||
mappings: Array<{ locale: string; expression: string; weight: number }>;
|
"grill",
|
||||||
}> = [
|
"panFry",
|
||||||
{
|
"blanch",
|
||||||
uid: "cook",
|
"marinate",
|
||||||
mappings: [
|
"chop",
|
||||||
{ locale: "fr", expression: "\\bcui(re|sez|sant|sson)\\b|\\bcuit(e|es|s)?\\b", weight: 10 },
|
"peel",
|
||||||
{ locale: "en", expression: "\\bcook(s|ed|ing)?\\b", weight: 10 },
|
"mince",
|
||||||
],
|
"mix",
|
||||||
},
|
"whisk",
|
||||||
{
|
"foldIn",
|
||||||
uid: "fry",
|
"setAside",
|
||||||
mappings: [
|
"season",
|
||||||
{ locale: "fr", expression: "\\bfri(re|t|te|ts|tes|ture)\\b", weight: 15 },
|
"drain",
|
||||||
{ locale: "en", expression: "\\bfr(y|ies|ied|ying)\\b", weight: 15 },
|
"brown",
|
||||||
],
|
"rest",
|
||||||
},
|
"preheat",
|
||||||
{
|
"bake",
|
||||||
uid: "melt",
|
"plate",
|
||||||
mappings: [
|
"coat",
|
||||||
{
|
|
||||||
locale: "fr",
|
|
||||||
expression:
|
|
||||||
"\\bfondre\\b|\\bfondu(e|es|s)?\\b|\\bfaire fondre\\b|\\bfaites fondre\\b|\\bfaire chauffer\\b|\\bfaites chauffer\\b",
|
|
||||||
weight: 15,
|
|
||||||
},
|
|
||||||
{ locale: "en", expression: "\\bmelt(s|ed|ing)?\\b", weight: 15 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "deglaze",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\bd[ée]glac(er|ez|é|ée|age)\\b", weight: 20 },
|
|
||||||
{ locale: "en", expression: "\\bdeglaz(e|es|ed|ing)\\b", weight: 20 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "simmer",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\bmijot(er|ez|e|ant|é)\\b", weight: 15 },
|
|
||||||
{ locale: "en", expression: "\\bsimmer(s|ed|ing)?\\b", weight: 15 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "boil",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\bbouill(ir|ant|ie|ies)\\b|\\b[ée]bullition\\b", weight: 12 },
|
|
||||||
{ locale: "en", expression: "\\bboil(s|ed|ing)?\\b", weight: 12 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "roast",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\br[ôo]tir\\b|\\br[ôo]ti(e|es|s)?\\b", weight: 15 },
|
|
||||||
{ locale: "en", expression: "\\broast(s|ed|ing)?\\b", weight: 15 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "grill",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\bgrill(er|ez|é|ée|ées|ade)\\b", weight: 15 },
|
|
||||||
{ locale: "en", expression: "\\bgrill(s|ed|ing)?\\b", weight: 15 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "panFry",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\bsaut(er|ez|é|ée|ées|ant)\\b", weight: 12 },
|
|
||||||
{
|
|
||||||
locale: "en",
|
|
||||||
expression: "\\bsaut[ée](s|ed|ing)?\\b|\\bpan[- ]?fr(y|ies|ied|ying)\\b",
|
|
||||||
weight: 12,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "blanch",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\bblanch(ir|issez|i|ie|ies|iment)\\b", weight: 18 },
|
|
||||||
{ locale: "en", expression: "\\bblanch(es|ed|ing)?\\b", weight: 18 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "marinate",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\bmarin(er|ez|é|ée|ées|ade)\\b", weight: 18 },
|
|
||||||
{ locale: "en", expression: "\\bmarinat(e|es|ed|ing)\\b|\\bmarinad(e|es)\\b", weight: 18 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "chop",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\bhach(er|ez|é|ée|ées|is)\\b", weight: 15 },
|
|
||||||
{ locale: "en", expression: "\\bchop(s|ped|ping)?\\b", weight: 15 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "peel",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\b[ée]pluch(er|ez|é|ée|ées|age)\\b", weight: 15 },
|
|
||||||
{ locale: "en", expression: "\\bpeel(s|ed|ing)?\\b", weight: 15 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "mince",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\b[ée]minc(er|ez|é|ée|ées)\\b", weight: 18 },
|
|
||||||
{ locale: "en", expression: "\\bminc(e|es|ed|ing)\\b", weight: 18 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "mix",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\bm[ée]lang(er|ez|é|ée|ées|e|es)\\b", weight: 10 },
|
|
||||||
{ locale: "en", expression: "\\bmix(es|ed|ing)?\\b|\\bcombine(s|d)?\\b", weight: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "whisk",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\bfouett(er|ez|é|ée|ées)\\b|\\bau fouet\\b", weight: 15 },
|
|
||||||
{ locale: "en", expression: "\\bwhisk(s|ed|ing)?\\b", weight: 15 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "foldIn",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\bincorpor(er|ez|é|ée|ées|ant)\\b", weight: 15 },
|
|
||||||
{ locale: "en", expression: "\\bfold(s|ed|ing)? in\\b", weight: 15 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "setAside",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\br[ée]serv(er|ez|é|ée|ées)\\b", weight: 15 },
|
|
||||||
{ locale: "en", expression: "\\bset(s)? aside\\b|\\bsetting aside\\b", weight: 15 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "season",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\bassaisonn(er|ez|é|ée|ées|ement)\\b", weight: 15 },
|
|
||||||
{ locale: "en", expression: "\\bseason(s|ed|ing)?\\b", weight: 15 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "drain",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\b[ée]goutt(er|ez|é|ée|ées)\\b", weight: 15 },
|
|
||||||
{ locale: "en", expression: "\\bdrain(s|ed|ing)?\\b", weight: 15 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "brown",
|
|
||||||
mappings: [
|
|
||||||
{
|
|
||||||
locale: "fr",
|
|
||||||
expression:
|
|
||||||
"\\bfaire revenir\\b|\\bfaites revenir\\b|\\bfais revenir\\b|\\bfaire dorer\\b|\\bfaites dorer\\b",
|
|
||||||
weight: 25,
|
|
||||||
},
|
|
||||||
// Verb forms only (not bare "brown"), which would false-positive on
|
|
||||||
// ingredient descriptions like "brown sugar"/"brown rice".
|
|
||||||
{ locale: "en", expression: "\\bbrown(ed|ing)\\b", weight: 25 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "rest",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\blaiss(er|ez|e) reposer\\b|\\breposer\\b", weight: 20 },
|
|
||||||
// Anchored to "let ... rest"/"rest for" rather than bare "rest",
|
|
||||||
// which would false-positive on phrases like "the rest of the".
|
|
||||||
{
|
|
||||||
locale: "en",
|
|
||||||
expression: "\\blet (it |them )?rest\\b|\\brest(s|ed|ing)? for\\b",
|
|
||||||
weight: 20,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "preheat",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b", weight: 20 },
|
|
||||||
{ locale: "en", expression: "\\bpreheat(s|ed|ing)?\\b", weight: 20 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "bake",
|
|
||||||
mappings: [
|
|
||||||
{
|
|
||||||
locale: "fr",
|
|
||||||
expression:
|
|
||||||
"\\bcuire au four\\b|\\bcuisson au four\\b|\\benfourn(er|ez|é|ée|ées)\\b|\\bau four\\b",
|
|
||||||
weight: 25,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
locale: "en",
|
|
||||||
expression: "\\bbak(e|es|ed|ing)\\b|\\bin (a|the) (preheated )?oven\\b",
|
|
||||||
weight: 25,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "plate",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\bdress(er|ez|age)\\b", weight: 15 },
|
|
||||||
{ locale: "en", expression: "\\bplat(e|es|ed|ing)\\b", weight: 15 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "coat",
|
|
||||||
mappings: [
|
|
||||||
{ locale: "fr", expression: "\\bnapp(er|ez|é|ée|ées|age)\\b", weight: 15 },
|
|
||||||
{ locale: "en", expression: "\\bcoat(s|ed|ing)?\\b", weight: 15 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
|
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
|
||||||
|
|
@ -1389,37 +1193,11 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TechStep: upsert by key (same idempotent-seed reasoning as everything
|
// TechStep: upsert by key (same idempotent-seed reasoning as everything
|
||||||
// above), then fully replace its mappings on every reseed. Mappings carry
|
// above) — just the stable id/key rows themselves now, no matching data
|
||||||
// no natural per-row identity to upsert against, and expressions/weights
|
// to replace alongside them (see `TECH_STEPS`' own comment for why).
|
||||||
// are expected to be tuned over time — a straight "delete all, recreate
|
for (const key of TECH_STEPS) {
|
||||||
// from source" keeps the table an exact mirror of `TECH_STEPS` rather
|
|
||||||
// than accumulating stale/duplicate rows from earlier edits. Nothing else
|
|
||||||
// references `TechStepMapping.id` (`Step` only points at `TechStep`, not
|
|
||||||
// at a specific mapping), so this replace is safe.
|
|
||||||
for (const { uid: key } of TECH_STEPS) {
|
|
||||||
await prisma.techStep.upsert({ where: { key }, update: {}, create: { key } });
|
await prisma.techStep.upsert({ where: { key }, update: {}, create: { key } });
|
||||||
}
|
}
|
||||||
const techSteps = await prisma.techStep.findMany({
|
|
||||||
where: { key: { in: TECH_STEPS.map((t) => t.uid) } },
|
|
||||||
});
|
|
||||||
const techStepIdByKey = new Map(techSteps.map((t) => [t.key, t.id]));
|
|
||||||
|
|
||||||
await prisma.techStepMapping.deleteMany({
|
|
||||||
where: { techStepId: { in: [...techStepIdByKey.values()] } },
|
|
||||||
});
|
|
||||||
const techStepMappingRows = TECH_STEPS.flatMap(({ uid, mappings }) => {
|
|
||||||
const techStepId = techStepIdByKey.get(uid);
|
|
||||||
if (techStepId === undefined) return [];
|
|
||||||
return mappings.map(({ locale, expression, weight }) => ({
|
|
||||||
techStepId,
|
|
||||||
locale,
|
|
||||||
expression,
|
|
||||||
weight,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
if (techStepMappingRows.length > 0) {
|
|
||||||
await prisma.techStepMapping.createMany({ data: techStepMappingRows });
|
|
||||||
}
|
|
||||||
|
|
||||||
// `Allergy` itself carries no `key` — it's the selectable instance of a
|
// `Allergy` itself carries no `key` — it's the selectable instance of a
|
||||||
// keyed `Category` (see schema.prisma) — so seeding an allergen means one
|
// keyed `Category` (see schema.prisma) — so seeding an allergen means one
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,9 @@ import { normalizeText } from "./tech-step-matcher.js";
|
||||||
* unit-testable without a database (see `test/ingredient-matcher.test.ts`);
|
* unit-testable without a database (see `test/ingredient-matcher.test.ts`);
|
||||||
* `loadIngredientCatalog`/`loadUnitCatalog` are the only DB-touching pieces,
|
* `loadIngredientCatalog`/`loadUnitCatalog` are the only DB-touching pieces,
|
||||||
* meant to be fetched once per request and reused across every ingredient
|
* meant to be fetched once per request and reused across every ingredient
|
||||||
* line, the same "don't requery per item" convention as
|
* line, the same "don't requery per item" convention
|
||||||
* `loadTechStepMappingRules`.
|
* `tech-step-matcher.ts`'s `TechStepClassifierService` follows for its own
|
||||||
|
* one-time training pass.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its English matching label. */
|
/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its English matching label. */
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,7 @@ import {
|
||||||
matchUnit,
|
matchUnit,
|
||||||
type UnitMatchEntry,
|
type UnitMatchEntry,
|
||||||
} from "./ingredient-matcher.js";
|
} from "./ingredient-matcher.js";
|
||||||
import {
|
import { techStepClassifier } from "./tech-step-matcher.js";
|
||||||
loadTechStepMappingRules,
|
|
||||||
matchTechSteps,
|
|
||||||
type TechStepMappingRule,
|
|
||||||
} from "./tech-step-matcher.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The "Traduction en étapes" stage of the import pipeline described in
|
* The "Traduction en étapes" stage of the import pipeline described in
|
||||||
|
|
@ -36,17 +32,18 @@ import {
|
||||||
* can't know those) — this is one step of the pipeline, not the whole
|
* can't know those) — this is one step of the pipeline, not the whole
|
||||||
* thing.
|
* thing.
|
||||||
*
|
*
|
||||||
* `translateRecipeSteps`/`translateRecipeIngredients` are pure (take their
|
* `translateRecipeIngredients` stays pure (takes its matching data as plain
|
||||||
* matching data as plain arguments, same convention as `matchTechSteps`/
|
* arguments, same convention `matchIngredientName` itself has) so it's
|
||||||
* `matchIngredientName` themselves) so they're unit-testable without a
|
* unit-testable without a database. `translateRecipeSteps` no longer is —
|
||||||
* database; `translateRecipe` is the DB-backed convenience wrapper a caller
|
* technique detection now goes through `techStepClassifier`'s trained
|
||||||
* reaches for in practice, mirroring `tech-step-matcher.ts`'s own
|
* model (`tech-step-matcher.ts`), which needs an async call — but is still
|
||||||
* pure/DB-touching split.
|
* exported separately from `translateRecipe` for callers/tests that only
|
||||||
|
* care about step translation, not ingredients too.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** A {@link ParsedRecipeStep}, after tech-step detection — declares its technique sequence alongside the description/picture it already had. */
|
/** A {@link ParsedRecipeStep}, after tech-step detection — declares its technique sequence alongside the description/picture it already had. */
|
||||||
export interface TranslatedRecipeStep extends ParsedRecipeStep {
|
export interface TranslatedRecipeStep extends ParsedRecipeStep {
|
||||||
/** Ordered sequence of detected `TechStep` ids (see `matchTechSteps`) — empty if this step doesn't mention any known technique. */
|
/** Ordered sequence of detected `TechStep` ids (see `TechStepClassifierService.matchTechSteps`) — empty if this step doesn't mention any known technique. */
|
||||||
techStepIds: number[];
|
techStepIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,34 +60,43 @@ export interface TranslatedRecipe extends Omit<ParsedRecipe, "steps" | "ingredie
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Declares each of `recipe`'s steps' technique sequence against
|
* Declares each of `recipe`'s steps' technique sequence for `locale`,
|
||||||
* `techStepMappings`, leaving everything else about the recipe untouched —
|
* leaving everything else about the recipe untouched — including
|
||||||
* including ingredients, which are only stubbed to `TranslatedRecipeIngredient`'s
|
* ingredients, which are only stubbed to `TranslatedRecipeIngredient`'s
|
||||||
* shape here (`ingredientId`/`unitId: null`, `quantity`/`unit` untouched);
|
* shape here (`ingredientId`/`unitId: null`, `quantity`/`unit` untouched);
|
||||||
* actually resolving them is {@link translateRecipeIngredients}'s job, kept
|
* actually resolving them is {@link translateRecipeIngredients}'s job, kept
|
||||||
* separate the same way tech-step and ingredient matching are two
|
* separate the same way tech-step and ingredient matching are two
|
||||||
* independent concerns everywhere else in this module. Pure — testable with
|
* independent concerns everywhere else in this module. Async — technique
|
||||||
* a hand-built mapping list, no database involved (see `translateRecipe`
|
* detection now runs against `techStepClassifier`'s trained model rather
|
||||||
* for the DB-backed loader). `techStepMappings` should already be filtered
|
* than a caller-supplied mapping list (see `tech-step-matcher.ts`), so this
|
||||||
* to the locale the caller cares about, same requirement `matchTechSteps`
|
* can no longer stay a plain synchronous function the way it used to.
|
||||||
* itself has.
|
|
||||||
*/
|
*/
|
||||||
export function translateRecipeSteps(
|
export async function translateRecipeSteps(
|
||||||
recipe: ParsedRecipe,
|
recipe: ParsedRecipe,
|
||||||
techStepMappings: TechStepMappingRule[],
|
locale: string,
|
||||||
): TranslatedRecipe {
|
): Promise<TranslatedRecipe> {
|
||||||
return {
|
try {
|
||||||
...recipe,
|
const steps = await Promise.all(
|
||||||
ingredients: recipe.ingredients.map((ingredient) => ({
|
recipe.steps.map(async (step) => ({
|
||||||
...ingredient,
|
...step,
|
||||||
ingredientId: null,
|
techStepIds: await techStepClassifier.matchTechSteps(step.description, locale),
|
||||||
unitId: null,
|
})),
|
||||||
})),
|
);
|
||||||
steps: recipe.steps.map((step) => ({
|
return {
|
||||||
...step,
|
...recipe,
|
||||||
techStepIds: matchTechSteps(step.description, techStepMappings),
|
ingredients: recipe.ingredients.map((ingredient) => ({
|
||||||
})),
|
...ingredient,
|
||||||
};
|
ingredientId: null,
|
||||||
|
unitId: null,
|
||||||
|
})),
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
// Rethrown as-is — the caller (`sources.service.ts`) already
|
||||||
|
// handles/logs failures centrally; this function just isn't allowed a
|
||||||
|
// bare `await` per the repo's async/try-catch convention.
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -259,12 +265,13 @@ export function mergeDuplicateIngredients(
|
||||||
* manually-authored recipes.
|
* manually-authored recipes.
|
||||||
*
|
*
|
||||||
* No user- or recipe-level language preference exists anywhere in the app
|
* No user- or recipe-level language preference exists anywhere in the app
|
||||||
* yet (see `tech-step-matcher.ts`'s `loadTechStepMappingRules`) — callers
|
* yet (see `tech-step-matcher.ts`'s `TechStepClassifierService`) — callers
|
||||||
* pass a locale explicitly rather than this module guessing one. Note that
|
* pass a locale explicitly rather than this module guessing one. Note that
|
||||||
* an English-language source (e.g. TheMealDB) translated against `"fr"`
|
* an English-language source (e.g. TheMealDB) translated against `"fr"`
|
||||||
* mappings will currently get an empty `techStepIds` sequence on every
|
* will currently get an empty (or nonsensical) `techStepIds` sequence on
|
||||||
* step — matching-language mappings for that source's language don't exist
|
* every step — the classifier is trained per-locale, so calling it with a
|
||||||
* yet, this stage doesn't invent them.
|
* locale that doesn't match the actual text's language doesn't degrade
|
||||||
|
* gracefully, it just gets things wrong.
|
||||||
*
|
*
|
||||||
* Ingredient/unit matching only has English data today
|
* Ingredient/unit matching only has English data today
|
||||||
* (`INGREDIENT_LABELS_EN`/`UNIT_LABELS_EN`, `packages/shared`) — for any
|
* (`INGREDIENT_LABELS_EN`/`UNIT_LABELS_EN`, `packages/shared`) — for any
|
||||||
|
|
@ -279,8 +286,7 @@ export async function translateRecipe(
|
||||||
locale: string,
|
locale: string,
|
||||||
): Promise<TranslatedRecipe> {
|
): Promise<TranslatedRecipe> {
|
||||||
try {
|
try {
|
||||||
const techStepMappings = await loadTechStepMappingRules(locale);
|
const translated = await translateRecipeSteps(recipe, locale);
|
||||||
const translated = translateRecipeSteps(recipe, techStepMappings);
|
|
||||||
|
|
||||||
if (locale !== "en") return translated;
|
if (locale !== "en") return translated;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,67 @@
|
||||||
|
import { NlpManager } from "node-nlp";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
|
import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe
|
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe
|
||||||
* step description corresponds to, using the static `TechStepMapping`
|
* step description corresponds to — groundwork for a future batch-cooking
|
||||||
* catalog (see `reference-seed-data.ts`'s `TECH_STEPS`) — groundwork for a
|
* optimization algorithm, and (via `matchTechStepSpans`) what
|
||||||
* future batch-cooking optimization algorithm, and (via `matchTechStepSpans`)
|
* `recipe.service.ts` persists as `StepTechStep.start`/`end` so the recipe
|
||||||
* what `recipe.service.ts` persists as `StepTechStep.start`/`end` so the
|
* UI can highlight the exact matched words (see `StepView` in
|
||||||
* recipe UI can highlight the exact matched words (see `StepView` in
|
|
||||||
* `packages/shared`).
|
* `packages/shared`).
|
||||||
*
|
*
|
||||||
* A single instruction can genuinely involve more than one technique (e.g.
|
* Regex-only matching used to live here (matching literal verb-form
|
||||||
* "Dans une poêle chaude, faire chauffer une noix de beurre" is both
|
* patterns from a DB-backed `TechStepMapping` table) but couldn't
|
||||||
* `preheat` and `melt`) — both `matchTechSteps`/`matchTechStepSpans` return
|
* generalize past its own vocabulary — a step describing melting butter as
|
||||||
* the whole *ordered sequence* they find, not a single winner, matching
|
* "jusqu'à ce que le beurre ait disparu dans la poêle" mentions no verb any
|
||||||
|
* regex could anchor on, yet unmistakably *means* `melt`. Replaced with a
|
||||||
|
* small hybrid pipeline built on `node-nlp` ({@link TechStepClassifierService}):
|
||||||
|
*
|
||||||
|
* 1. **NER** (node-nlp enum entities, `synonyms` in `TECH_STEP_TRAINING_DATA`)
|
||||||
|
* finds every *candidate* technique mention in the whole
|
||||||
|
* description, each with its exact character span — mechanically the
|
||||||
|
* same job the old regexes did, just as flat synonym lists instead of
|
||||||
|
* hand-written patterns (node-nlp's own stemmer/fuzzy matching already
|
||||||
|
* covers minor conjugation/typo variance the regexes had to enumerate
|
||||||
|
* by hand). This step alone is *not* the final answer — see step 3.
|
||||||
|
* 2. The description is cut into clauses around those candidate spans
|
||||||
|
* ({@link splitIntoClauses}) — a step naming two techniques ("Dans une
|
||||||
|
* poêle chaude, faire chauffer une noix de beurre" is both `preheat`
|
||||||
|
* and `melt`) needs each judged on its own surrounding context, not the
|
||||||
|
* whole step lumped into one classification.
|
||||||
|
* 3. **NLP intent classification** (node-nlp's `NlpManager`, trained on
|
||||||
|
* `TECH_STEP_TRAINING_DATA`'s `utterances`) then classifies each clause
|
||||||
|
* on its own — this is what actually delivers "meaning, not keywords":
|
||||||
|
* the classifier was deliberately trained on paraphrases that never use
|
||||||
|
* the technique's own verb (e.g. "jusqu'à ce que le beurre ait
|
||||||
|
* disparu" for `melt`), so a clause reaching it gets labeled by what it
|
||||||
|
* was trained to recognize as *meaning* a technique, not by which
|
||||||
|
* literal word the NER step happened to anchor on. The NER-implied
|
||||||
|
* technique is kept only as a fallback for a clause the classifier
|
||||||
|
* isn't confident about (see `CONFIDENCE_THRESHOLD`) — a clearly
|
||||||
|
* keyword-anchored clause a small model merely isn't sure how to
|
||||||
|
* classify shouldn't be dropped outright.
|
||||||
|
*
|
||||||
|
* A single instruction can genuinely involve more than one technique — see
|
||||||
|
* point 2 above — so `matchTechSteps`/`matchTechStepSpans` both return the
|
||||||
|
* whole *ordered sequence* they find, not a single winner, matching
|
||||||
* `Step.techSteps` (schema.prisma's `StepTechStep`, an ordered join table).
|
* `Step.techSteps` (schema.prisma's `StepTechStep`, an ordered join table).
|
||||||
*
|
*
|
||||||
* `normalizeText`/`matchTechStepSpans`/`matchTechSteps` are pure (no DB
|
* `normalizeText` and {@link splitIntoClauses} are pure (no DB/model
|
||||||
* access) so they can be unit-tested in isolation (see
|
* access) so they stay unit-testable in isolation (see
|
||||||
* `test/tech-step-matcher.test.ts`). `loadTechStepMappingRules` is the only
|
* `test/tech-step-matcher.test.ts`); the classifier itself needs a one-time
|
||||||
* DB-touching piece, kept separate so callers (`recipe.service.ts`) fetch
|
* training pass (`_ensureTrained`, node-nlp's `NlpManager.train()`) plus a
|
||||||
* the whole mapping list once per request and pass it to
|
* `TechStep.key -> id` lookup from the DB, both memoized on the shared
|
||||||
* `matchTechStepSpans` per step, rather than querying once per step.
|
* {@link techStepClassifier} singleton rather than repeated per call —
|
||||||
|
* training is the expensive part (a few hundred ms for this corpus), never
|
||||||
|
* worth redoing per request let alone per step.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** One `TechStepMapping` row, trimmed to what {@link matchTechSteps} needs. */
|
|
||||||
export interface TechStepMappingRule {
|
|
||||||
techStepId: number;
|
|
||||||
/**
|
|
||||||
* Regex source, matched against the normalized description (see
|
|
||||||
* {@link normalizeText}) — may itself contain accented characters,
|
|
||||||
* normalized the same way before compiling.
|
|
||||||
*/
|
|
||||||
expression: string;
|
|
||||||
weight: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lowercases and strips diacritics (NFD decomposition + removal of
|
* Lowercases and strips diacritics (NFD decomposition + removal of
|
||||||
* combining marks, e.g. "Déglacer" -> "deglacer") — recipe step text and
|
* combining marks, e.g. "Déglacer" -> "deglacer"). Still used by
|
||||||
* mapping expressions are both run through this before matching, so
|
* `ingredient-matcher.ts` for its own, unrelated free-text matching — kept
|
||||||
* expressions can be authored with natural French accents in
|
* here and exported rather than duplicated, this module owned it first.
|
||||||
* `reference-seed-data.ts` while matching stays accent/case-insensitive.
|
|
||||||
*/
|
*/
|
||||||
const COMBINING_DIACRITICS_PATTERN = /\p{Diacritic}/gu;
|
const COMBINING_DIACRITICS_PATTERN = /\p{Diacritic}/gu;
|
||||||
|
|
||||||
|
|
@ -48,155 +69,415 @@ export function normalizeText(text: string): string {
|
||||||
return text.normalize("NFD").replace(COMBINING_DIACRITICS_PATTERN, "").toLowerCase();
|
return text.normalize("NFD").replace(COMBINING_DIACRITICS_PATTERN, "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where in the (normalized) description one mapping matched, alongside the rule that matched — the raw material {@link matchTechStepSpans} resolves into a final sequence. */
|
|
||||||
interface MatchCandidate extends TechStepMappingRule {
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether two candidates' matched spans share any character position — the case where two *different* techniques' expressions matched the same words (e.g. generic `cook`'s "cuire" inside specific `bake`'s "cuire au four"), meaning only one of them should survive. */
|
|
||||||
function overlaps(a: MatchCandidate, b: MatchCandidate): boolean {
|
|
||||||
return a.start < b.end && b.start < a.end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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. */
|
||||||
|
export interface TechniqueCandidate {
|
||||||
|
/** Training-data `uid` this candidate's synonym belongs to (e.g. `"melt"`) — not yet resolved to a DB id at this stage. */
|
||||||
|
uid: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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, 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 as a match's `start`/`end` for the keyword highlight, the wider clause span is always its `contextStart`/`contextEnd`. */
|
||||||
|
anchor: TechniqueCandidate | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches a sentence-ending punctuation mark, for {@link findGapSplitPoint}'s preferred split points. */
|
||||||
|
const SENTENCE_END_PATTERN = /[.!?]/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks where to cut the gap `[gapStart, gapEnd)` between two consecutive
|
||||||
|
* candidates — preferring a *sentence* boundary (right after `.`/`!`/`?`)
|
||||||
|
* nearest the gap's midpoint when one exists in the gap, otherwise any
|
||||||
|
* whitespace nearest the 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.
|
||||||
|
*
|
||||||
|
* The sentence-boundary preference matters beyond cosmetics: a description
|
||||||
|
* with two techniques in two different sentences ("Préchauffer le four à
|
||||||
|
* 180°C. Dans un saladier, mettre le beurre... et mélanger.") used to only
|
||||||
|
* get a plain nearest-midpoint whitespace split, which for a long first
|
||||||
|
* sentence lands *inside* the second one — handing the classifier a clause
|
||||||
|
* like "...(thermostat 6). Dans un saladier, mettre" that trails off
|
||||||
|
* mid-instruction with no object. That garbled, incomplete text is nothing
|
||||||
|
* like the short, complete training utterances, and was found to
|
||||||
|
* misclassify real recipe steps with high (>0.65) confidence in both
|
||||||
|
* halves — "Préchauffer..." scored as `mix`, its actual "mélanger" clause
|
||||||
|
* as `melt`. Splitting at the real sentence boundary instead hands the
|
||||||
|
* classifier two complete, grammatical clauses, each far closer to what it
|
||||||
|
* was trained on.
|
||||||
|
*
|
||||||
|
* 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 bestSentenceEnd: number | null = null;
|
||||||
|
let bestSentenceEndDistance = Number.POSITIVE_INFINITY;
|
||||||
|
let bestWhitespace: number | null = null;
|
||||||
|
let bestWhitespaceDistance = Number.POSITIVE_INFINITY;
|
||||||
|
for (let i = gapStart; i < gapEnd; i++) {
|
||||||
|
if (!/\s/.test(description[i] ?? "")) continue;
|
||||||
|
const distance = Math.abs(i - midpoint);
|
||||||
|
if (distance < bestWhitespaceDistance) {
|
||||||
|
bestWhitespace = i;
|
||||||
|
bestWhitespaceDistance = distance;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
i > gapStart &&
|
||||||
|
SENTENCE_END_PATTERN.test(description[i - 1] ?? "") &&
|
||||||
|
distance < bestSentenceEndDistance
|
||||||
|
) {
|
||||||
|
bestSentenceEnd = i;
|
||||||
|
bestSentenceEndDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestSentenceEnd ?? bestWhitespace ?? midpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects every technique `description` mentions among `mappings`, as an
|
* Cuts `description` into clauses around `candidates` (NER's found
|
||||||
* ordered sequence of matches (each carrying *where* it matched) — empty if
|
* technique mentions, already sorted or not — sorted internally), one
|
||||||
* none match. The algorithm:
|
* clause per candidate, so each can be judged by the classifier on its own
|
||||||
|
* surrounding context rather than the whole (possibly multi-technique)
|
||||||
|
* description at once.
|
||||||
*
|
*
|
||||||
* 1. Test every mapping against the normalized description; each one that
|
* - **Zero candidates**: the whole description is one clause with no
|
||||||
* matches becomes a candidate carrying *where* it matched (so
|
* anchor — still worth classifying (a description mentioning no literal
|
||||||
* overlapping matches can be compared).
|
* keyword at all can still *mean* a technique, the entire point of the
|
||||||
* 2. Within a single technique, several of its own mappings might all
|
* classification step), just with no tight span to highlight, so callers
|
||||||
* match (different phrasings for the same `techStepId`) — keep only
|
* fall back to highlighting the whole thing.
|
||||||
* that technique's best candidate (highest weight, ties broken by
|
* - **One candidate**: the whole description is one clause too (nothing to
|
||||||
* earliest match), the same tie-break this function always used for a
|
* cut around a single mention), but *with* that candidate as its anchor
|
||||||
* single winner.
|
* — callers get its tight span for highlighting.
|
||||||
* 3. Across *different* techniques, two candidates can still overlap (a
|
* - **Two or more**: split points fall at the whitespace nearest the
|
||||||
* generic pattern matching inside a more specific one's span, e.g.
|
* midpoint of each consecutive pair's `[end, nextStart]` gap (see
|
||||||
* `cook` vs `bake` both matching "cuire au four") — resolve greedily by
|
* {@link findGapSplitPoint} — never mid-word), producing that many
|
||||||
* weight: take candidates highest-weight first, accept a candidate only
|
* contiguous, non-overlapping clauses covering the whole description —
|
||||||
* if it doesn't overlap one already accepted. This is what keeps
|
* clause *i* is anchored on candidate *i*.
|
||||||
* `bake` and drops the redundant `cook` for that phrase, while letting
|
|
||||||
* two genuinely distinct, non-overlapping techniques (e.g. `preheat`
|
|
||||||
* and `melt` in "Dans une poêle chaude, faire chauffer une noix de
|
|
||||||
* beurre") both survive.
|
|
||||||
* 4. Sort what's left by where it appears in the text — the sequence
|
|
||||||
* reads in the same order as the instruction itself.
|
|
||||||
*
|
*
|
||||||
* The returned `start`/`end` are offsets into `normalizeText(description)`,
|
* Pure and DB/model-free — unit-tested directly (see
|
||||||
* used as-is against the *original* `description` by callers that slice it
|
* `test/tech-step-matcher.test.ts`) without needing a trained classifier.
|
||||||
* for display (`highlight-tech-steps.ts`, apps/web) — `normalizeText` only
|
|
||||||
* strips diacritics/lowercases, which preserves character count for
|
|
||||||
* realistic French text (canonical NFD decomposition never turns one
|
|
||||||
* character into more than one base character), so this holds in practice.
|
|
||||||
* A pathological input where it doesn't (e.g. a bare standalone `^`, which
|
|
||||||
* `normalizeText` would strip as a diacritic) just produces a slightly
|
|
||||||
* misplaced highlight — degrades silently, doesn't crash.
|
|
||||||
*
|
|
||||||
* Pure — takes `mappings` as a plain argument rather than querying Prisma
|
|
||||||
* itself, so it's testable without a database (see
|
|
||||||
* `loadTechStepMappingRules` for the DB-backed loader). `mappings` should
|
|
||||||
* already be filtered to the locale the caller cares about — this function
|
|
||||||
* has no notion of locale, it just tests the rules it's given.
|
|
||||||
*/
|
*/
|
||||||
export function matchTechStepSpans(
|
export function splitIntoClauses(
|
||||||
description: string,
|
description: string,
|
||||||
mappings: TechStepMappingRule[],
|
candidates: TechniqueCandidate[],
|
||||||
): TechStepMatch[] {
|
): TechStepClause[] {
|
||||||
const normalizedDescription = normalizeText(description);
|
if (candidates.length === 0) {
|
||||||
|
return [{ start: 0, end: description.length, anchor: null }];
|
||||||
|
}
|
||||||
|
|
||||||
const candidates: MatchCandidate[] = [];
|
const sorted = [...candidates].sort((a, b) => a.start - b.start);
|
||||||
for (const mapping of mappings) {
|
const [first, ...rest] = sorted;
|
||||||
const pattern = new RegExp(normalizeText(mapping.expression), "i");
|
if (first === undefined) {
|
||||||
const match = pattern.exec(normalizedDescription);
|
// Unreachable — `candidates.length === 0` already returned above, so
|
||||||
if (match === null) continue;
|
// `sorted` (same length) always has a first element here. Satisfies
|
||||||
candidates.push({
|
// `noUncheckedIndexedAccess`, which can't see that from the length
|
||||||
...mapping,
|
// check alone.
|
||||||
start: match.index,
|
return [{ start: 0, end: description.length, anchor: null }];
|
||||||
end: match.index + match[0].length,
|
}
|
||||||
|
|
||||||
|
// Single pass, pairing each candidate with the next one as it goes —
|
||||||
|
// avoids re-indexing a separately-built `splitPoints` array afterward
|
||||||
|
// (also awkward under `noUncheckedIndexedAccess` for no real benefit,
|
||||||
|
// since every split point is only ever read once, right after it's
|
||||||
|
// computed).
|
||||||
|
const clauses: TechStepClause[] = [];
|
||||||
|
let clauseStart = 0;
|
||||||
|
let anchor = first;
|
||||||
|
for (const next of rest) {
|
||||||
|
const splitPoint = findGapSplitPoint(description, anchor.end, next.start);
|
||||||
|
clauses.push({ start: clauseStart, end: splitPoint, anchor });
|
||||||
|
clauseStart = splitPoint;
|
||||||
|
anchor = next;
|
||||||
|
}
|
||||||
|
clauses.push({ start: clauseStart, end: description.length, anchor });
|
||||||
|
return clauses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Below this confidence, a clause's classifier verdict isn't trusted on its
|
||||||
|
* own — falls back to its NER anchor's own technique instead (see this
|
||||||
|
* file's doc comment, point 3). Tuned empirically against
|
||||||
|
* `TECH_STEP_TRAINING_DATA` — see `test/tech-step-matcher.test.ts` for the
|
||||||
|
* cases this threshold was picked to pass.
|
||||||
|
*
|
||||||
|
* Raised from `0.65` after finding real (non-adversarial) misclassified
|
||||||
|
* clauses that scored just above the old threshold — e.g. English recipe
|
||||||
|
* text run through the French classifier (which must find *nothing*,
|
||||||
|
* confirmed by `recipe-translation.test.ts`'s own locale-isolation test)
|
||||||
|
* scored `0.69` for `boil`, essentially classifier noise on
|
||||||
|
* out-of-vocabulary input rather than a real, confident verdict. The
|
||||||
|
* clauses this threshold exists to actually trust score far higher in
|
||||||
|
* practice (`0.91`–`1.0` for the real corrected cases found this session)
|
||||||
|
* — `0.75` sits comfortably above the noise floor and below every genuine
|
||||||
|
* match seen so far.
|
||||||
|
*/
|
||||||
|
const CONFIDENCE_THRESHOLD = 0.75;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trains and owns the `node-nlp` model behind {@link matchTechStepSpans} —
|
||||||
|
* a real class (not a plain object of functions) per this repo's
|
||||||
|
* service-style-logic convention, even though it's only ever used as the
|
||||||
|
* one shared {@link techStepClassifier} singleton below: it holds real
|
||||||
|
* state (the trained model, the memoized training/lookup promises), not
|
||||||
|
* just grouped stateless helpers.
|
||||||
|
*/
|
||||||
|
export class TechStepClassifierService {
|
||||||
|
/** node-nlp's manager — both NER (enum entities) and NLP (intent classification) live on the same instance, trained together. */
|
||||||
|
private readonly _manager: NlpManager;
|
||||||
|
/** Memoized training pass — `undefined` until the first call starts it, after which every caller (concurrent or not) awaits the same promise rather than retraining. */
|
||||||
|
private _trained: Promise<void> | undefined;
|
||||||
|
/** Memoized `TechStep.key -> id` lookup — training data only knows techniques by their stable `uid`/`key`, resolved to the real DB id once, alongside training. */
|
||||||
|
private _techStepIdByUid: Map<string, number> | undefined;
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
this._manager = new NlpManager({
|
||||||
|
languages: ["fr", "en"],
|
||||||
|
forceNER: true,
|
||||||
|
nlu: { log: false },
|
||||||
|
// node-nlp's enum-entity NER defaults to a fuzzy (Levenshtein-based)
|
||||||
|
// 0.8 accuracy threshold — loose enough that e.g. "faire" (the
|
||||||
|
// generic French helper verb in almost every recipe step) fuzzy-
|
||||||
|
// matches `fry`'s synonym "frire" at 0.80, a false positive found
|
||||||
|
// while tuning this against the real training corpus. `1` (exact,
|
||||||
|
// after node-nlp's own case/accent/stemming normalization — real
|
||||||
|
// conjugation variance is still covered by listing each form in
|
||||||
|
// `tech-step-training-data.ts`) removed it without losing any real
|
||||||
|
// match. Precision matters more than recall for this stage — NER
|
||||||
|
// only proposes candidate split points, `_classifyClause`'s trained
|
||||||
|
// model (not fuzzy string distance) is what actually has to be
|
||||||
|
// right.
|
||||||
|
ner: { threshold: 1 },
|
||||||
|
// node-nlp defaults to `autoSave`/`autoLoad: true` — silently
|
||||||
|
// persisting the trained model to a `model.nlp` file in the process's
|
||||||
|
// cwd, and *loading from that file instead of retraining* the next
|
||||||
|
// time a manager is constructed, if the file already exists. Found
|
||||||
|
// this the hard way: a stray `model.nlp` appeared at the repo root
|
||||||
|
// after running this locally. That's the opposite of what this
|
||||||
|
// service wants — `TECH_STEP_TRAINING_DATA` in code is the single
|
||||||
|
// source of truth this always trains fresh from (see this file's own
|
||||||
|
// doc comment) — a stale on-disk model silently shadowing a
|
||||||
|
// corpus/threshold update would be a nasty, hard-to-notice class of
|
||||||
|
// bug. Both off; nothing here should ever touch disk.
|
||||||
|
autoSave: false,
|
||||||
|
autoLoad: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: one best candidate per techStepId.
|
/**
|
||||||
const bestByTechStep = new Map<number, MatchCandidate>();
|
* Forces training plus node-nlp's own one-time lazy setup (loading its
|
||||||
for (const candidate of candidates) {
|
* bundled per-language stemmers/tokenizers on the *first* real
|
||||||
const current = bestByTechStep.get(candidate.techStepId);
|
* `NlpManager.process()` call takes a few seconds by itself, separate
|
||||||
if (
|
* from and much slower than the ~40ms `train()` pass — measured against
|
||||||
current === undefined ||
|
* this corpus while tuning the pipeline) to happen now, synchronously
|
||||||
candidate.weight > current.weight ||
|
* with server startup (see `server.ts`), rather than stalling whichever
|
||||||
(candidate.weight === current.weight && candidate.start < current.start)
|
* request happens to be first to save/preview a recipe.
|
||||||
) {
|
*/
|
||||||
bestByTechStep.set(candidate.techStepId, candidate);
|
public async warmUp(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.matchTechStepSpans("faire cuire à feu doux", "fr");
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see matchTechStepSpans()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: resolve cross-technique overlaps, highest weight first.
|
/**
|
||||||
const byWeightDesc = [...bestByTechStep.values()].sort(
|
* Detects every technique `description` means, as an ordered sequence of
|
||||||
(a, b) => b.weight - a.weight || a.techStepId - b.techStepId,
|
* matches (each carrying *where* it matched) — empty if none apply. See
|
||||||
);
|
* this file's doc comment for the full NER -> split -> classify
|
||||||
const accepted: MatchCandidate[] = [];
|
* pipeline.
|
||||||
for (const candidate of byWeightDesc) {
|
*
|
||||||
if (accepted.some((other) => overlaps(candidate, other))) continue;
|
* @param locale Which of `TECH_STEP_TRAINING_DATA`'s locales to match
|
||||||
accepted.push(candidate);
|
* against — same "caller already knows/validated this" contract the
|
||||||
|
* old `matchTechStepSpans(description, mappings)` had via its
|
||||||
|
* pre-filtered `mappings` argument, just as an explicit parameter now
|
||||||
|
* that the training data isn't pre-filtered by the caller anymore.
|
||||||
|
*/
|
||||||
|
public async matchTechStepSpans(description: string, locale: string): Promise<TechStepMatch[]> {
|
||||||
|
try {
|
||||||
|
await this._ensureTrained();
|
||||||
|
if (description.trim().length === 0) return [];
|
||||||
|
|
||||||
|
const nerResult = await this._manager.process(locale, description);
|
||||||
|
const candidates: TechniqueCandidate[] = nerResult.entities
|
||||||
|
// node-nlp's language plugins also auto-extract their own built-in
|
||||||
|
// entities (numbers, durations, dates…) alongside the enum
|
||||||
|
// entities `_train` registered from `TECH_STEP_TRAINING_DATA` —
|
||||||
|
// `type === "enum"` is what tells the two apart; without this
|
||||||
|
// filter a step like "10 minutes" would hand `splitIntoClauses` a
|
||||||
|
// bogus "duration" candidate that resolves to no real technique.
|
||||||
|
.filter((entity) => entity.type === "enum")
|
||||||
|
.map((entity) => ({
|
||||||
|
uid: entity.entity,
|
||||||
|
start: entity.start,
|
||||||
|
// node-nlp's own `end` is inclusive (verified against a real
|
||||||
|
// trained model) — `+ 1` converts to this module's `[start, end)`
|
||||||
|
// convention, matching `String.prototype.slice`.
|
||||||
|
end: entity.end + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const clauses = splitIntoClauses(description, candidates);
|
||||||
|
const matches: TechStepMatch[] = [];
|
||||||
|
for (const clause of clauses) {
|
||||||
|
const uid = await this._classifyClause(description, clause, locale);
|
||||||
|
if (uid === null) continue;
|
||||||
|
const techStepId = this._techStepIdByUid?.get(uid);
|
||||||
|
// A `uid` the classifier/NER was trained on but that no longer has
|
||||||
|
// a matching `TechStep` row (e.g. training data and
|
||||||
|
// `reference-seed-data.ts` drifted apart) — skip rather than
|
||||||
|
// 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,
|
||||||
|
contextStart: clause.start,
|
||||||
|
contextEnd: clause.end,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId);
|
||||||
|
return matches;
|
||||||
|
} catch (err) {
|
||||||
|
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||||
|
// already logs it, see `error-logger.ts`) is what actually handles
|
||||||
|
// it, this service layer just isn't allowed a bare `await` without a
|
||||||
|
// try/catch per the repo's convention.
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: reading order.
|
/**
|
||||||
accepted.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId);
|
* Convenience wrapper around {@link matchTechStepSpans} for callers that
|
||||||
return accepted.map(({ techStepId, start, end }) => ({
|
* only care about *which* techniques matched, not where — e.g.
|
||||||
techStepId,
|
* `recipe-translation.ts`'s `translateRecipeSteps`, which declares a
|
||||||
start,
|
* step's technique sequence for an imported recipe that isn't saved (and
|
||||||
end,
|
* so has no `StepTechStep` row to persist a span into) yet.
|
||||||
}));
|
*/
|
||||||
}
|
public async matchTechSteps(description: string, locale: string): Promise<number[]> {
|
||||||
|
try {
|
||||||
|
return (await this.matchTechStepSpans(description, locale)).map((match) => match.techStepId);
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see matchTechStepSpans()'s catch comment above
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience wrapper around {@link matchTechStepSpans} for callers that
|
* Classifies one clause, returning the technique `uid` it means (or
|
||||||
* only care about *which* techniques matched, not where — e.g.
|
* `null` if none applies) — the classifier's own verdict when it's
|
||||||
* `recipe-translation.ts`'s `translateRecipeSteps`, which declares a step's
|
* confident enough ({@link CONFIDENCE_THRESHOLD}), otherwise the
|
||||||
* technique sequence for an imported recipe that isn't saved (and so has no
|
* clause's NER anchor (if it has one) as a floor: a clearly
|
||||||
* `StepTechStep` row to persist a span into) yet.
|
* keyword-anchored clause a small model merely isn't sure how to
|
||||||
*/
|
* classify shouldn't be dropped outright, only a genuinely
|
||||||
export function matchTechSteps(description: string, mappings: TechStepMappingRule[]): number[] {
|
* anchor-less/low-confidence one should.
|
||||||
return matchTechStepSpans(description, mappings).map((match) => match.techStepId);
|
*/
|
||||||
}
|
private async _classifyClause(
|
||||||
|
description: string,
|
||||||
|
clause: TechStepClause,
|
||||||
|
locale: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const clauseText = description.slice(clause.start, clause.end).trim();
|
||||||
|
if (clauseText.length === 0) return clause.anchor?.uid ?? null;
|
||||||
|
|
||||||
/**
|
const result = await this._manager.process(locale, clauseText);
|
||||||
* Loads every `TechStepMapping` row for `locale` as
|
if (result.intent !== "None" && result.score >= CONFIDENCE_THRESHOLD) {
|
||||||
* {@link TechStepMappingRule}s — meant to be fetched once per request by
|
return result.intent;
|
||||||
* `recipe.service.ts`'s `createRecipe`/`updateRecipe` and reused across
|
}
|
||||||
* every step of the recipe being saved, not re-queried per step.
|
return clause.anchor?.uid ?? null;
|
||||||
*
|
} catch (err) {
|
||||||
* No user-language preference exists anywhere in the app yet (a single
|
throw err; // see matchTechStepSpans()'s catch comment above
|
||||||
* `"fr"` translation file, no locale field on `User`/`UserProfile`) —
|
}
|
||||||
* callers pass a hardcoded locale for now; this parameter exists so that
|
}
|
||||||
* plugging in a real user preference later doesn't require touching this
|
|
||||||
* module.
|
/**
|
||||||
*/
|
* Trains `_manager` from {@link TECH_STEP_TRAINING_DATA} and resolves the
|
||||||
export async function loadTechStepMappingRules(locale: string): Promise<TechStepMappingRule[]> {
|
* `uid -> TechStep.id` lookup, both exactly once — memoized on
|
||||||
try {
|
* `_trained` so a burst of concurrent calls (several steps of the same
|
||||||
return await prisma.techStepMapping.findMany({
|
* recipe save, awaited via the same event loop tick) all await the one
|
||||||
where: { locale },
|
* in-flight training pass rather than each kicking off their own.
|
||||||
select: { techStepId: true, expression: true, weight: true },
|
*/
|
||||||
});
|
private async _ensureTrained(): Promise<void> {
|
||||||
} catch (err) {
|
if (this._trained === undefined) {
|
||||||
// Rethrown as-is — the caller (`recipe.service.ts`/`sources.service.ts`)
|
this._trained = this._train();
|
||||||
// already handles/logs failures centrally; this function just isn't
|
}
|
||||||
// allowed a bare `async` body without a try/catch per the repo's
|
try {
|
||||||
// convention.
|
await this._trained;
|
||||||
throw err;
|
} catch (err) {
|
||||||
|
// A failed training pass must be retried by the *next* call, not
|
||||||
|
// leave every future call permanently rejecting against a stale
|
||||||
|
// failed promise.
|
||||||
|
this._trained = undefined;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _train(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
|
||||||
|
this._techStepIdByUid = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
|
||||||
|
|
||||||
|
for (const entry of TECH_STEP_TRAINING_DATA) {
|
||||||
|
for (const [locale, data] of [
|
||||||
|
["fr", entry.fr],
|
||||||
|
["en", entry.en],
|
||||||
|
] as const) {
|
||||||
|
if (data.synonyms.length > 0) {
|
||||||
|
this._manager.addNamedEntityText(entry.uid, entry.uid, [locale], data.synonyms);
|
||||||
|
}
|
||||||
|
for (const utterance of data.utterances) {
|
||||||
|
this._manager.addDocument(locale, utterance, entry.uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._manager.train();
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see matchTechStepSpans()'s catch comment above
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Single shared instance — training is expensive enough (a few hundred ms) that every caller must reuse the one already-trained model, never spin up their own. */
|
||||||
|
export const techStepClassifier = new TechStepClassifierService();
|
||||||
|
|
|
||||||
911
apps/api/src/lib/recipe-matching/tech-step-training-data.ts
Normal file
911
apps/api/src/lib/recipe-matching/tech-step-training-data.ts
Normal file
|
|
@ -0,0 +1,911 @@
|
||||||
|
/**
|
||||||
|
* Training corpus for {@link TechStepClassifierService} (`tech-step-matcher.ts`)
|
||||||
|
* — one entry per `TechStep` (`uid` matches `reference-seed-data.ts`'s
|
||||||
|
* `TECH_STEPS`, which still owns the reference `TechStep` rows themselves;
|
||||||
|
* this file replaces `TECH_STEPS[].mappings`' regex expressions as the
|
||||||
|
* *matching* data source).
|
||||||
|
*
|
||||||
|
* Two distinct kinds of content per technique/locale, feeding two distinct
|
||||||
|
* mechanisms of the classifier (see that file's doc comment for why both
|
||||||
|
* are needed):
|
||||||
|
*
|
||||||
|
* - `synonyms` — short literal words/set phrases, fed to node-nlp's NER
|
||||||
|
* (enum entities). Mechanically equivalent to the old regexes' verb-form
|
||||||
|
* alternations, just spelled out as plain words instead of a pattern
|
||||||
|
* (node-nlp's own stemmer/fuzzy matching already covers minor
|
||||||
|
* conjugation/typo variance that the regexes had to enumerate by hand).
|
||||||
|
* Used only to find *candidate* technique mentions and cut a step into
|
||||||
|
* clauses around them — never the final answer on their own.
|
||||||
|
* - `utterances` — full example clauses, fed to node-nlp's NLP Manager as
|
||||||
|
* training documents for the intent classifier. Deliberately mixes
|
||||||
|
* keyword-anchored phrasings (reinforces the obvious case) with
|
||||||
|
* paraphrases that never use the technique's own verb at all (e.g.
|
||||||
|
* "jusqu'à ce que le beurre ait disparu" for `melt`) — this second kind
|
||||||
|
* is what actually delivers on "comprendre le sens, pas juste les mots
|
||||||
|
* clés" (see the PR this file was introduced in): a clause reaching the
|
||||||
|
* classifier gets labeled by what it's trained to recognize as *meaning*
|
||||||
|
* this technique, not by which literal word triggered its extraction.
|
||||||
|
*
|
||||||
|
* Kept as static in-code data (not DB rows, unlike the old
|
||||||
|
* `TechStepMapping` table) because nothing needs to query/edit it at
|
||||||
|
* runtime — it only ever feeds one thing, the classifier's one-time
|
||||||
|
* training pass (see `TechStepClassifierService._ensureTrained`) — same
|
||||||
|
* reasoning `INGREDIENT_LABELS_EN` (`packages/shared`) is a plain object,
|
||||||
|
* not a database table.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One technique's matching data for one locale — see this file's doc comment for what each list feeds. */
|
||||||
|
export interface TechStepLocaleTrainingData {
|
||||||
|
synonyms: string[];
|
||||||
|
utterances: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One technique's full training entry — `uid` must match a `TECH_STEPS[].uid` in `reference-seed-data.ts`. */
|
||||||
|
export interface TechStepTrainingEntry {
|
||||||
|
uid: string;
|
||||||
|
fr: TechStepLocaleTrainingData;
|
||||||
|
en: TechStepLocaleTrainingData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
|
{
|
||||||
|
uid: "cook",
|
||||||
|
fr: {
|
||||||
|
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",
|
||||||
|
"la cuisson dure environ dix minutes",
|
||||||
|
"jusqu'à ce que la viande ne soit plus rose au centre",
|
||||||
|
"poursuivre la cuisson à couvert",
|
||||||
|
// Two real recipe clauses found misclassified (as `preheat` and
|
||||||
|
// `panFry` respectively, both above the confidence threshold) once
|
||||||
|
// real, longer, comma-heavy sentences started reaching the
|
||||||
|
// classifier — neither error came from a missing keyword (both
|
||||||
|
// clauses' own NER anchor, "laisser cuire"/"faire cuire", was
|
||||||
|
// already right), just the classifier's low-heat/occasional-
|
||||||
|
// stirring phrasing not resembling anything short and clean-cut it
|
||||||
|
// had actually been trained on.
|
||||||
|
"baisser le feu et laisser cuire à découvert encore un quart d'heure",
|
||||||
|
"faire cuire à feu doux en remuant de temps en temps",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
"cook until done",
|
||||||
|
"cooking takes about ten minutes",
|
||||||
|
"until no longer pink in the middle",
|
||||||
|
"continue cooking covered",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "fry",
|
||||||
|
fr: {
|
||||||
|
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",
|
||||||
|
"jusqu'à ce que ce soit doré et croustillant à l'extérieur",
|
||||||
|
"l'huile doit être bien chaude avant d'y plonger les morceaux",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// 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",
|
||||||
|
"until crisp and golden on the outside",
|
||||||
|
"the oil should be very hot before adding the pieces",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "melt",
|
||||||
|
fr: {
|
||||||
|
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",
|
||||||
|
"le beurre doit être complètement liquide",
|
||||||
|
"laisser le fromage devenir tout liquide sur feu doux",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: ["melt", "melts", "melted", "melting", "liquefy", "liquefied"],
|
||||||
|
utterances: [
|
||||||
|
"melt the butter",
|
||||||
|
"until the butter has completely disappeared into the pan",
|
||||||
|
"the butter should be fully liquid",
|
||||||
|
"let the cheese turn completely liquid over low heat",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
"verser le vin dans la poêle chaude pour décoller les sucs",
|
||||||
|
"gratter les sucs de cuisson au fond de la casserole avec un peu de bouillon",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// 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",
|
||||||
|
"scrape up the browned bits at the bottom of the pan with a splash of stock",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "simmer",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"mijoter",
|
||||||
|
"mijotez",
|
||||||
|
"mijote",
|
||||||
|
"mijotant",
|
||||||
|
"mijoté",
|
||||||
|
"frémir",
|
||||||
|
"frémissant",
|
||||||
|
"frémissante",
|
||||||
|
"à petit feu",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"laisser mijoter à feu doux",
|
||||||
|
"faire mijoter pendant une heure",
|
||||||
|
"de petites bulles doivent remonter doucement à la surface",
|
||||||
|
"laisser cuire tout doucement à couvert pendant longtemps",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// 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",
|
||||||
|
"small bubbles should gently rise to the surface",
|
||||||
|
"let it cook very gently, covered, for a long time",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "boil",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"bouillir",
|
||||||
|
"bouillant",
|
||||||
|
"bouillie",
|
||||||
|
"bouillies",
|
||||||
|
"ébullition",
|
||||||
|
"porter à ébullition",
|
||||||
|
"gros bouillons",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"porter à ébullition",
|
||||||
|
"faire bouillir l'eau",
|
||||||
|
"de grosses bulles doivent agiter la surface avec force",
|
||||||
|
"jusqu'à ce que ça bouillonne franchement",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// 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",
|
||||||
|
"large bubbles should be vigorously breaking the surface",
|
||||||
|
"until it's rolling vigorously",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "roast",
|
||||||
|
fr: {
|
||||||
|
// 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",
|
||||||
|
"cuire la pièce de viande entière au four à chaleur sèche",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: ["roast", "roasts", "roasted", "roasting", "oven-roast", "oven roasted"],
|
||||||
|
utterances: [
|
||||||
|
"roast the whole bird",
|
||||||
|
"it should brown evenly on every side",
|
||||||
|
"cook the whole piece of meat in dry oven heat",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "grill",
|
||||||
|
fr: {
|
||||||
|
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",
|
||||||
|
"des traces de quadrillage doivent apparaître à la cuisson",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: ["grill", "grills", "grilled", "grilling", "barbecue", "char-grill", "charbroiled"],
|
||||||
|
utterances: [
|
||||||
|
"grill on the barbecue rack",
|
||||||
|
"sear the steaks on a scorching-hot plate",
|
||||||
|
"char marks should appear as it cooks",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
"saisir rapidement à feu vif en remuant sans cesse",
|
||||||
|
"faire revenir en remuant vivement dans une poêle très chaude",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
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",
|
||||||
|
"cook briskly, stirring, in a very hot pan",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "blanch",
|
||||||
|
fr: {
|
||||||
|
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",
|
||||||
|
"cuire très rapidement à l'eau bouillante avant de stopper la cuisson au froid",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// "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",
|
||||||
|
"cook very quickly in boiling water before stopping it cold",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "marinate",
|
||||||
|
fr: {
|
||||||
|
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",
|
||||||
|
"laisser reposer dans le mélange d'huile et d'épices avant de cuisiner",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// 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",
|
||||||
|
"let it sit in the oil and spice mixture before cooking",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "chop",
|
||||||
|
fr: {
|
||||||
|
// 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",
|
||||||
|
"réduire les herbes en petits fragments avant de les ajouter",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// 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",
|
||||||
|
"break the herbs down into small bits before adding them",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "peel",
|
||||||
|
fr: {
|
||||||
|
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",
|
||||||
|
"ôter la pelure du fruit avant de le couper",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: ["peel", "peels", "peeled", "peeling", "pare", "pared", "paring"],
|
||||||
|
utterances: [
|
||||||
|
"peel the potatoes",
|
||||||
|
"remove the skin from the carrots with a peeler",
|
||||||
|
"take the skin off the fruit before cutting it",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "mince",
|
||||||
|
fr: {
|
||||||
|
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: {
|
||||||
|
// 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",
|
||||||
|
"slice into strips as thin as possible",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "mix",
|
||||||
|
fr: {
|
||||||
|
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",
|
||||||
|
"remuer jusqu'à obtenir une préparation homogène",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
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",
|
||||||
|
"stir until the mixture is smooth and even",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "whisk",
|
||||||
|
fr: {
|
||||||
|
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", "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",
|
||||||
|
// 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",
|
||||||
|
"mélanger tout doucement de bas en haut pour garder l'air emprisonné",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
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",
|
||||||
|
"very gently stir from the bottom up to keep the air trapped in",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "setAside",
|
||||||
|
fr: {
|
||||||
|
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",
|
||||||
|
"laisser attendre sur le plan de travail pendant la préparation du reste",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
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",
|
||||||
|
"let it wait on the counter while you prepare the rest",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "season",
|
||||||
|
fr: {
|
||||||
|
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",
|
||||||
|
"ajouter du sel selon votre goût avant de servir",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: ["season", "seasons", "seasoned", "seasoning", "spice it up", "add seasoning"],
|
||||||
|
utterances: [
|
||||||
|
"season with salt and pepper",
|
||||||
|
"adjust the taste by adding spices",
|
||||||
|
"add salt to taste before serving",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "drain",
|
||||||
|
fr: {
|
||||||
|
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",
|
||||||
|
"laisser l'excédent d'eau s'écouler avant de servir",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: ["drain", "drains", "drained", "draining", "strain", "strained", "straining"],
|
||||||
|
utterances: [
|
||||||
|
"drain the pasta in a colander",
|
||||||
|
"pour into a colander to remove the cooking water",
|
||||||
|
"let the excess water run off before serving",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "brown",
|
||||||
|
fr: {
|
||||||
|
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",
|
||||||
|
"saisir jusqu'à ce que la surface prenne une belle couleur caramel",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// Verb forms only (not bare "brown"), same reasoning the old regex
|
||||||
|
// doc comment gave — a bare "brown" false-positives on ingredient
|
||||||
|
// descriptions like "brown sugar"/"brown rice", which never get to
|
||||||
|
// the classifier since they're not step text, but keeping the
|
||||||
|
// synonym itself anchored costs nothing and stays consistent.
|
||||||
|
synonyms: ["browned", "browning"],
|
||||||
|
utterances: [
|
||||||
|
"brown the onions in hot oil",
|
||||||
|
"brown the meat on every side",
|
||||||
|
"sear until the surface turns a deep caramel color",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "rest",
|
||||||
|
fr: {
|
||||||
|
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",
|
||||||
|
"attendre quelques minutes avant de servir pour que les jus se répartissent",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
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", "resting time"],
|
||||||
|
utterances: [
|
||||||
|
"let the dough rest for thirty minutes",
|
||||||
|
"let the meat relax outside the oven before carving it",
|
||||||
|
"wait a few minutes before serving so the juices redistribute",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "preheat",
|
||||||
|
fr: {
|
||||||
|
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",
|
||||||
|
"allumer le four à l'avance pour qu'il soit à température",
|
||||||
|
// A pan gets preheated too, not just an oven — without an example
|
||||||
|
// like this, "poêle" (which also appears throughout `panFry`'s own
|
||||||
|
// training utterances) biased the classifier toward `panFry` for
|
||||||
|
// any preheating clause that happens to mention a pan, found while
|
||||||
|
// testing against the classic "Préchauffer la poêle, puis faire
|
||||||
|
// fondre le beurre" case.
|
||||||
|
"préchauffer la poêle avant d'y verser l'huile",
|
||||||
|
"faire chauffer la poêle à vide quelques minutes",
|
||||||
|
// "poêle" + "feu vif" together still read as `panFry` (the act of
|
||||||
|
// actually cooking something in it) rather than `preheat` (getting
|
||||||
|
// it hot beforehand, nothing in it yet) without an example this
|
||||||
|
// close to that exact wording — found via "mettre la poêle sur feu
|
||||||
|
// vif" (no food mentioned at all) still classifying as panFry.
|
||||||
|
"mettre la poêle vide sur feu vif avant d'ajouter quoi que ce soit",
|
||||||
|
"mettre la poêle sur feu vif",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// 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",
|
||||||
|
"switch the oven on ahead of time so it's up to temperature",
|
||||||
|
"preheat the pan before adding the oil",
|
||||||
|
"heat the empty pan for a few minutes first",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "bake",
|
||||||
|
fr: {
|
||||||
|
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é",
|
||||||
|
"cuire dans le four préchauffé jusqu'à ce que la surface soit ferme",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// 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",
|
||||||
|
"cook in the preheated oven until the surface is firm",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "plate",
|
||||||
|
fr: {
|
||||||
|
// 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",
|
||||||
|
"présenter avec soin au centre de l'assiette",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
"arrange it neatly on the plate before serving",
|
||||||
|
"present it carefully in the center of the plate",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "coat",
|
||||||
|
fr: {
|
||||||
|
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",
|
||||||
|
"verser la sauce par-dessus pour bien enrober",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// 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",
|
||||||
|
"pour the sauce over it so it's well covered",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -16,9 +16,9 @@
|
||||||
* user has already brought in as if they were new. A separate, pure
|
* user has already brought in as if they were new. A separate, pure
|
||||||
* step rather than something `list()` itself does: an adapter only
|
* step rather than something `list()` itself does: an adapter only
|
||||||
* knows its source, never our database — same reasoning as
|
* knows its source, never our database — same reasoning as
|
||||||
* `tech-step-matcher.ts`'s split between pure `matchTechStep` and its
|
* `tech-step-matcher.ts`'s split between pure `splitIntoClauses` and
|
||||||
* DB-touching `loadTechStepMappingRules`. Whichever future layer
|
* its DB/model-touching `TechStepClassifierService`. Whichever future
|
||||||
* queries "which externalIds from this source do we already have"
|
* layer queries "which externalIds from this source do we already have"
|
||||||
* (not yet decided — it needs a place to persist that link,
|
* (not yet decided — it needs a place to persist that link,
|
||||||
* see {@link RecipeSourceListItem.externalId}) calls this to annotate
|
* see {@link RecipeSourceListItem.externalId}) calls this to annotate
|
||||||
* the page before returning it.
|
* the page before returning it.
|
||||||
|
|
@ -177,7 +177,7 @@ export interface RecipeSourceAdapter<TRawDetail = unknown> {
|
||||||
* `steps[].description`/`ingredients[].name`) — e.g. `"en"` for
|
* `steps[].description`/`ingredients[].name`) — e.g. `"en"` for
|
||||||
* TheMealDB. Not a user preference: the language the source's own
|
* TheMealDB. Not a user preference: the language the source's own
|
||||||
* content is actually written in, regardless of who's browsing it.
|
* content is actually written in, regardless of who's browsing it.
|
||||||
* Determines which `TechStepMapping`/ingredient-label locale
|
* Determines which trained-classifier/ingredient-label locale
|
||||||
* `translateRecipe` (`recipe-translation.ts`) resolves this source's
|
* `translateRecipe` (`recipe-translation.ts`) resolves this source's
|
||||||
* recipes against when previewing/importing one.
|
* recipes against when previewing/importing one.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,15 @@ import {
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import {
|
import {
|
||||||
loadTechStepMappingRules,
|
type TechStepMatch,
|
||||||
matchTechStepSpans,
|
techStepClassifier,
|
||||||
} from "../../lib/recipe-matching/tech-step-matcher.js";
|
} from "../../lib/recipe-matching/tech-step-matcher.js";
|
||||||
|
|
||||||
// No user-language preference exists anywhere in the app yet (a single
|
// No user-language preference exists anywhere in the app yet (a single
|
||||||
// "fr" translation file, no locale field on User/UserProfile) — steps are
|
// "fr" translation file, no locale field on User/UserProfile) — steps are
|
||||||
// matched against this hardcoded locale for now. See
|
// matched against this hardcoded locale for now. See
|
||||||
// `tech-step-matcher.ts`'s `loadTechStepMappingRules` for why the locale is
|
// `tech-step-matcher.ts`'s `TechStepClassifierService` for why the locale
|
||||||
// a parameter rather than baked into that module.
|
// is a parameter rather than baked into that module.
|
||||||
const DEFAULT_TECH_STEP_LOCALE = "fr";
|
const DEFAULT_TECH_STEP_LOCALE = "fr";
|
||||||
|
|
||||||
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */
|
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -454,6 +458,36 @@ export async function createImportedRecipe(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One input step, bundled with its own technique matches — see {@link matchStepsTechSteps}. */
|
||||||
|
interface StepWithTechSteps<T> {
|
||||||
|
step: T;
|
||||||
|
matches: TechStepMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches every one of `steps`' technique sequence against `locale`, in
|
||||||
|
* parallel, each bundled back with its own originating step (rather than
|
||||||
|
* returned as a same-length array callers would have to re-zip with
|
||||||
|
* `steps` by index — `noUncheckedIndexedAccess` makes that genuinely
|
||||||
|
* awkward for no benefit, since every match list is only ever read back
|
||||||
|
* once) — the shared prep step {@link createRecipeInternal}/
|
||||||
|
* {@link updateRecipe} both need before building their (synchronous)
|
||||||
|
* Prisma `create` payload, now that matching itself is async
|
||||||
|
* (`techStepClassifier`, a trained model rather than a pure regex test —
|
||||||
|
* see `tech-step-matcher.ts`).
|
||||||
|
*/
|
||||||
|
async function matchStepsTechSteps<T extends { description: string }>(
|
||||||
|
steps: T[],
|
||||||
|
locale: string,
|
||||||
|
): Promise<StepWithTechSteps<T>[]> {
|
||||||
|
return Promise.all(
|
||||||
|
steps.map(async (step) => ({
|
||||||
|
step,
|
||||||
|
matches: await techStepClassifier.matchTechStepSpans(step.description, locale),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function createRecipeInternal(
|
async function createRecipeInternal(
|
||||||
input: CreateRecipeInput,
|
input: CreateRecipeInput,
|
||||||
authorId: number,
|
authorId: number,
|
||||||
|
|
@ -464,7 +498,13 @@ async function createRecipeInternal(
|
||||||
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||||
await assertDietsExist(input.dietIds);
|
await assertDietsExist(input.dietIds);
|
||||||
const techStepMappings = await loadTechStepMappingRules(
|
// Matched up front (one call per step, in parallel) rather than inline
|
||||||
|
// inside the `steps.create` map below — `techStepClassifier` is async
|
||||||
|
// (a trained model, not a pure regex test), so its result has to
|
||||||
|
// already be in hand by the time this synchronous Prisma payload is
|
||||||
|
// built.
|
||||||
|
const stepsWithTechSteps = await matchStepsTechSteps(
|
||||||
|
input.steps,
|
||||||
source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
|
source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -487,19 +527,19 @@ async function createRecipeInternal(
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
steps: {
|
steps: {
|
||||||
create: input.steps.map((step, index) => ({
|
create: stepsWithTechSteps.map(({ step, matches }, index) => ({
|
||||||
description: step.description,
|
description: step.description,
|
||||||
picture: step.picture ?? null,
|
picture: step.picture ?? null,
|
||||||
order: index,
|
order: index,
|
||||||
techSteps: {
|
techSteps: {
|
||||||
create: matchTechStepSpans(step.description, techStepMappings).map(
|
create: matches.map((match, order) => ({
|
||||||
(match, order) => ({
|
techStepId: match.techStepId,
|
||||||
techStepId: match.techStepId,
|
start: match.start,
|
||||||
start: match.start,
|
end: match.end,
|
||||||
end: match.end,
|
contextStart: match.contextStart,
|
||||||
order,
|
contextEnd: match.contextEnd,
|
||||||
}),
|
order,
|
||||||
),
|
})),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
|
@ -537,7 +577,7 @@ export async function updateRecipe(
|
||||||
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||||
await assertDietsExist(input.dietIds);
|
await assertDietsExist(input.dietIds);
|
||||||
const techStepMappings = await loadTechStepMappingRules(DEFAULT_TECH_STEP_LOCALE);
|
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
|
||||||
|
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }),
|
prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }),
|
||||||
|
|
@ -559,19 +599,19 @@ export async function updateRecipe(
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
steps: {
|
steps: {
|
||||||
create: input.steps.map((step, index) => ({
|
create: stepsWithTechSteps.map(({ step, matches }, index) => ({
|
||||||
description: step.description,
|
description: step.description,
|
||||||
picture: step.picture ?? null,
|
picture: step.picture ?? null,
|
||||||
order: index,
|
order: index,
|
||||||
techSteps: {
|
techSteps: {
|
||||||
create: matchTechStepSpans(step.description, techStepMappings).map(
|
create: matches.map((match, order) => ({
|
||||||
(match, order) => ({
|
techStepId: match.techStepId,
|
||||||
techStepId: match.techStepId,
|
start: match.start,
|
||||||
start: match.start,
|
end: match.end,
|
||||||
end: match.end,
|
contextStart: match.contextStart,
|
||||||
order,
|
contextEnd: match.contextEnd,
|
||||||
}),
|
order,
|
||||||
),
|
})),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,7 @@ import {
|
||||||
mergeDuplicateIngredients,
|
mergeDuplicateIngredients,
|
||||||
translateRecipeIngredients,
|
translateRecipeIngredients,
|
||||||
} from "../../lib/recipe-matching/recipe-translation.js";
|
} from "../../lib/recipe-matching/recipe-translation.js";
|
||||||
import {
|
import { techStepClassifier } from "../../lib/recipe-matching/tech-step-matcher.js";
|
||||||
loadTechStepMappingRules,
|
|
||||||
matchTechStepSpans,
|
|
||||||
} from "../../lib/recipe-matching/tech-step-matcher.js";
|
|
||||||
import {
|
import {
|
||||||
markAlreadyImported,
|
markAlreadyImported,
|
||||||
type RecipeSourceAdapter,
|
type RecipeSourceAdapter,
|
||||||
|
|
@ -172,14 +169,20 @@ export async function previewSourceItem(
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [techStepMappings, ingredientCatalog, unitCatalog, techStepsByKey] = await Promise.all([
|
const [stepsWithTechStepMatches, ingredientCatalog, unitCatalog, techStepsByKey] =
|
||||||
loadTechStepMappingRules(adapter.locale),
|
await Promise.all([
|
||||||
adapter.locale === "en"
|
Promise.all(
|
||||||
? loadIngredientCatalog()
|
parsed.steps.map(async (step) => ({
|
||||||
: Promise.resolve<IngredientMatchEntry[]>([]),
|
step,
|
||||||
adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve<UnitMatchEntry[]>([]),
|
matches: await techStepClassifier.matchTechStepSpans(step.description, adapter.locale),
|
||||||
prisma.techStep.findMany({ select: { id: true, key: true } }),
|
})),
|
||||||
]);
|
),
|
||||||
|
adapter.locale === "en"
|
||||||
|
? loadIngredientCatalog()
|
||||||
|
: Promise.resolve<IngredientMatchEntry[]>([]),
|
||||||
|
adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve<UnitMatchEntry[]>([]),
|
||||||
|
prisma.techStep.findMany({ select: { id: true, key: true } }),
|
||||||
|
]);
|
||||||
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
|
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
|
||||||
|
|
||||||
const translatedIngredients = translateRecipeIngredients(
|
const translatedIngredients = translateRecipeIngredients(
|
||||||
|
|
@ -209,12 +212,22 @@ export async function previewSourceItem(
|
||||||
unit: ingredient.unitId !== null ? (unitById.get(ingredient.unitId) ?? null) : null,
|
unit: ingredient.unitId !== null ? (unitById.get(ingredient.unitId) ?? null) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const steps: DraftRecipeStepView[] = parsed.steps.map((step) => ({
|
const steps: DraftRecipeStepView[] = stepsWithTechStepMatches.map(({ step, matches }) => ({
|
||||||
description: step.description,
|
description: step.description,
|
||||||
picture: step.picture,
|
picture: step.picture,
|
||||||
techSteps: matchTechStepSpans(step.description, techStepMappings).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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
60
apps/api/src/scripts/backfill-tech-steps.ts
Normal file
60
apps/api/src/scripts/backfill-tech-steps.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { prisma } from "../db/prisma.js";
|
||||||
|
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-off maintenance script: recomputes every existing `Step`'s
|
||||||
|
* `StepTechStep` sequence against the *current* classifier
|
||||||
|
* (`tech-step-matcher.ts`/`tech-step-training-data.ts`), the same way
|
||||||
|
* `updateRecipe` does when a user resaves a recipe through the UI —
|
||||||
|
* always `"fr"` (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`; there's
|
||||||
|
* no persisted per-recipe locale to recover for a step that already exists,
|
||||||
|
* so this matches real resave behavior exactly rather than guessing).
|
||||||
|
*
|
||||||
|
* Needed because tech-step detection only ever runs at create/update time
|
||||||
|
* (`recipe.service.ts`'s `matchStepsTechSteps`), never retroactively — a
|
||||||
|
* step saved before a classifier/corpus change (new vocabulary, or the
|
||||||
|
* `contextStart`/`contextEnd` columns this same session added) keeps
|
||||||
|
* whatever it was matched with at the time until it's next resaved. Run
|
||||||
|
* this after a corpus change to bring every existing step in sync without
|
||||||
|
* asking users to open and resave every recipe by hand:
|
||||||
|
*
|
||||||
|
* pnpm --filter api exec tsx src/scripts/backfill-tech-steps.ts
|
||||||
|
*
|
||||||
|
* Safe to re-run: each step's technique sequence is fully replaced (delete
|
||||||
|
* + recreate) from the classifier's current output, same as a real edit —
|
||||||
|
* running it twice in a row with no corpus change in between is a no-op.
|
||||||
|
*/
|
||||||
|
async function backfillTechSteps(): Promise<void> {
|
||||||
|
const steps = await prisma.step.findMany({ select: { id: true, description: true } });
|
||||||
|
console.info(`Recomputing tech steps for ${steps.length} step(s)...`);
|
||||||
|
|
||||||
|
let changed = 0;
|
||||||
|
for (const step of steps) {
|
||||||
|
const matches = await techStepClassifier.matchTechStepSpans(step.description, "fr");
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.stepTechStep.deleteMany({ where: { stepId: step.id } }),
|
||||||
|
prisma.stepTechStep.createMany({
|
||||||
|
data: matches.map((match, order) => ({
|
||||||
|
stepId: step.id,
|
||||||
|
techStepId: match.techStepId,
|
||||||
|
order,
|
||||||
|
start: match.start,
|
||||||
|
end: match.end,
|
||||||
|
contextStart: match.contextStart,
|
||||||
|
contextEnd: match.contextEnd,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
changed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`Done — ${changed} step(s) recomputed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
backfillTechSteps()
|
||||||
|
.then(() => prisma.$disconnect())
|
||||||
|
.catch(async (err) => {
|
||||||
|
console.error(err);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { createServer } from "./app.js";
|
import { createServer } from "./app.js";
|
||||||
import { env } from "./config/env.js";
|
import { env } from "./config/env.js";
|
||||||
import { logger } from "./lib/logger.service.js";
|
import { logger } from "./lib/logger.service.js";
|
||||||
|
import { techStepClassifier } from "./lib/recipe-matching/tech-step-matcher.js";
|
||||||
import { registerAllRecipeSources } from "./sources/index.js";
|
import { registerAllRecipeSources } from "./sources/index.js";
|
||||||
|
|
||||||
// Populates the recipe-source registry (recipe-source-registry.ts) before
|
// Populates the recipe-source registry (recipe-source-registry.ts) before
|
||||||
|
|
@ -8,6 +9,22 @@ import { registerAllRecipeSources } from "./sources/index.js";
|
||||||
// doesn't happen inside app.ts/createServer() itself.
|
// doesn't happen inside app.ts/createServer() itself.
|
||||||
registerAllRecipeSources();
|
registerAllRecipeSources();
|
||||||
|
|
||||||
|
// Trains the tech-step classifier (and pays node-nlp's own one-time lazy
|
||||||
|
// setup cost — see `TechStepClassifierService.warmUp`) before accepting
|
||||||
|
// any traffic, so the first real recipe save/preview isn't the one stuck
|
||||||
|
// waiting several seconds for it.
|
||||||
|
try {
|
||||||
|
await techStepClassifier.warmUp();
|
||||||
|
} catch (err) {
|
||||||
|
// Not fatal to startup — a failed warm-up just means the *next* call
|
||||||
|
// retries training itself (see `_ensureTrained`'s own retry-on-failure
|
||||||
|
// comment), same graceful-degrade posture as everywhere else training
|
||||||
|
// failures surface. Still worth a loud log: this shouldn't normally fail.
|
||||||
|
logger.error("Tech-step classifier warm-up failed", {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
|
|
||||||
server.listen(env.PORT, () => {
|
server.listen(env.PORT, () => {
|
||||||
|
|
|
||||||
50
apps/api/src/types/node-nlp.d.ts
vendored
Normal file
50
apps/api/src/types/node-nlp.d.ts
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* Minimal ambient typing for `node-nlp` (no official/DefinitelyTyped types
|
||||||
|
* exist for it) — declares only the `NlpManager` surface
|
||||||
|
* `tech-step-matcher.ts` actually calls, verified against the real
|
||||||
|
* package (v4.27.0) rather than the library's full documented API, which
|
||||||
|
* this repo doesn't use the rest of.
|
||||||
|
*/
|
||||||
|
declare module "node-nlp" {
|
||||||
|
/** Constructor options this repo passes — `NlpManager` accepts more, only what's used here is typed. */
|
||||||
|
export interface NlpManagerOptions {
|
||||||
|
languages?: string[];
|
||||||
|
forceNER?: boolean;
|
||||||
|
nlu?: { log?: boolean };
|
||||||
|
ner?: { threshold?: number };
|
||||||
|
/** Defaults to `true` — persists the trained model to `modelFileName` (default `model.nlp`, in `process.cwd()`). See `tech-step-matcher.ts`'s own constructor comment for why this repo always sets it `false`. */
|
||||||
|
autoSave?: boolean;
|
||||||
|
/** Defaults to `true` — loads from `modelFileName` instead of training fresh if that file already exists. Always `false` here, same reasoning as `autoSave`. */
|
||||||
|
autoLoad?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entity `NlpManager.process`'s result reports — see `tech-step-matcher.ts`'s own `NerEntity` for the subset this repo reads. */
|
||||||
|
export interface NlpEntity {
|
||||||
|
entity: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
type: string;
|
||||||
|
accuracy?: number;
|
||||||
|
sourceText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `NlpManager.process`'s result — trimmed to the fields this repo reads (the real object carries many more). */
|
||||||
|
export interface NlpProcessResult {
|
||||||
|
intent: string;
|
||||||
|
score: number;
|
||||||
|
entities: NlpEntity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NlpManager {
|
||||||
|
public constructor(options?: NlpManagerOptions);
|
||||||
|
public addNamedEntityText(
|
||||||
|
entityName: string,
|
||||||
|
optionName: string,
|
||||||
|
languages: string[],
|
||||||
|
texts: string[],
|
||||||
|
): void;
|
||||||
|
public addDocument(locale: string, utterance: string, intent: string): void;
|
||||||
|
public train(): Promise<void>;
|
||||||
|
public process(locale: string, text: string): Promise<NlpProcessResult>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,7 +45,7 @@ export async function resetDatabase() {
|
||||||
TRUNCATE TABLE
|
TRUNCATE TABLE
|
||||||
"user_profile_allergy", "user_preference", "allergy", "category",
|
"user_profile_allergy", "user_preference", "allergy", "category",
|
||||||
"planning_item", "planning",
|
"planning_item", "planning",
|
||||||
"recipe_ingredient", "step_tech_step", "step", "tech_step_mapping", "tech_step",
|
"recipe_ingredient", "step_tech_step", "step", "tech_step",
|
||||||
"recipe", "ingredients", "sources", "unit",
|
"recipe", "ingredients", "sources", "unit",
|
||||||
"user_profiles", "diet", "house"
|
"user_profiles", "diet", "house"
|
||||||
RESTART IDENTITY CASCADE;
|
RESTART IDENTITY CASCADE;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
translateRecipeSteps,
|
translateRecipeSteps,
|
||||||
type UnitConversionEntry,
|
type UnitConversionEntry,
|
||||||
} from "../../src/lib/recipe-matching/recipe-translation.js";
|
} from "../../src/lib/recipe-matching/recipe-translation.js";
|
||||||
import type { TechStepMappingRule } from "../../src/lib/recipe-matching/tech-step-matcher.js";
|
|
||||||
import type {
|
import type {
|
||||||
ParsedRecipe,
|
ParsedRecipe,
|
||||||
ParsedRecipeIngredient,
|
ParsedRecipeIngredient,
|
||||||
|
|
@ -36,56 +35,66 @@ function buildParsedRecipe(descriptions: string[]): ParsedRecipe {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("recipe-translation", () => {
|
describe("recipe-translation", () => {
|
||||||
|
// `translateRecipeSteps` now goes through `techStepClassifier` (a
|
||||||
|
// trained model, not a pure regex test against a caller-supplied
|
||||||
|
// mapping list — see `tech-step-matcher.ts`), so these tests exercise
|
||||||
|
// the real training corpus (`tech-step-training-data.ts`) against a real
|
||||||
|
// `TechStep` catalog rather than synthetic fixtures — same posture
|
||||||
|
// `tech-step-matcher.test.ts`'s own `techStepClassifier` describe block
|
||||||
|
// takes, for the same reason.
|
||||||
describe("translateRecipeSteps", () => {
|
describe("translateRecipeSteps", () => {
|
||||||
const simmer: TechStepMappingRule = {
|
let simmerId: number;
|
||||||
techStepId: 1,
|
let preheatId: number;
|
||||||
expression: "\\bmijot(er|ez|e|ant|é)\\b",
|
let meltId: number;
|
||||||
weight: 15,
|
|
||||||
};
|
|
||||||
const preheat: TechStepMappingRule = {
|
|
||||||
techStepId: 2,
|
|
||||||
expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b",
|
|
||||||
weight: 20,
|
|
||||||
};
|
|
||||||
const melt: TechStepMappingRule = {
|
|
||||||
techStepId: 3,
|
|
||||||
expression: "\\bfaire fondre\\b|\\bfaites fondre\\b",
|
|
||||||
weight: 15,
|
|
||||||
};
|
|
||||||
|
|
||||||
it("declares each step's technique sequence, preserving order", () => {
|
beforeEach(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
simmerId = (await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } })).id;
|
||||||
|
preheatId = (await prisma.techStep.findFirstOrThrow({ where: { key: "preheat" } })).id;
|
||||||
|
meltId = (await prisma.techStep.findFirstOrThrow({ where: { key: "melt" } })).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares each step's technique sequence, preserving order", async () => {
|
||||||
const recipe = buildParsedRecipe([
|
const recipe = buildParsedRecipe([
|
||||||
"Préchauffer la poêle, puis faire fondre le beurre",
|
"Préchauffer la poêle, puis faire fondre le beurre",
|
||||||
"Servir immédiatement",
|
"Servir immédiatement",
|
||||||
"Faire mijoter à feu doux",
|
"Faire mijoter à feu doux",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const translated = translateRecipeSteps(recipe, [simmer, preheat, melt]);
|
const translated = await translateRecipeSteps(recipe, "fr");
|
||||||
|
|
||||||
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[2, 3], [], [1]]);
|
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([
|
||||||
|
[preheatId, meltId],
|
||||||
|
[],
|
||||||
|
[simmerId],
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves description/picture untouched on each step", () => {
|
it("leaves description/picture untouched on each step", async () => {
|
||||||
const recipe = buildParsedRecipe(["Faire mijoter à feu doux", "Servir"]);
|
const recipe = buildParsedRecipe(["Faire mijoter à feu doux", "Servir immédiatement"]);
|
||||||
|
|
||||||
const translated = translateRecipeSteps(recipe, [simmer]);
|
const translated = await translateRecipeSteps(recipe, "fr");
|
||||||
|
|
||||||
expect(translated.steps[0]).to.deep.equal({
|
expect(translated.steps[0]).to.deep.equal({
|
||||||
description: "Faire mijoter à feu doux",
|
description: "Faire mijoter à feu doux",
|
||||||
picture: "https://example.test/step1.jpg",
|
picture: "https://example.test/step1.jpg",
|
||||||
techStepIds: [1],
|
techStepIds: [simmerId],
|
||||||
});
|
});
|
||||||
expect(translated.steps[1]).to.deep.equal({
|
expect(translated.steps[1]).to.deep.equal({
|
||||||
description: "Servir",
|
description: "Servir immédiatement",
|
||||||
picture: null,
|
picture: null,
|
||||||
techStepIds: [],
|
techStepIds: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes every other field through unchanged", () => {
|
it("passes every other field through unchanged", async () => {
|
||||||
const recipe = buildParsedRecipe(["Servir"]);
|
const recipe = buildParsedRecipe(["Servir immédiatement"]);
|
||||||
|
|
||||||
const translated = translateRecipeSteps(recipe, []);
|
const translated = await translateRecipeSteps(recipe, "fr");
|
||||||
|
|
||||||
expect(translated.name).to.equal(recipe.name);
|
expect(translated.name).to.equal(recipe.name);
|
||||||
expect(translated.description).to.equal(recipe.description);
|
expect(translated.description).to.equal(recipe.description);
|
||||||
|
|
@ -94,28 +103,31 @@ describe("recipe-translation", () => {
|
||||||
expect(translated.sourceUrl).to.equal(recipe.sourceUrl);
|
expect(translated.sourceUrl).to.equal(recipe.sourceUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stubs every ingredient's ingredientId/unitId to null, leaving the rest of it untouched — actual ingredient matching is translateRecipeIngredients' job", () => {
|
it("stubs every ingredient's ingredientId/unitId to null, leaving the rest of it untouched — actual ingredient matching is translateRecipeIngredients' job", async () => {
|
||||||
const recipe = buildParsedRecipe(["Servir"]);
|
const recipe = buildParsedRecipe(["Servir immédiatement"]);
|
||||||
|
|
||||||
const translated = translateRecipeSteps(recipe, []);
|
const translated = await translateRecipeSteps(recipe, "fr");
|
||||||
|
|
||||||
expect(translated.ingredients).to.deep.equal([
|
expect(translated.ingredients).to.deep.equal([
|
||||||
{ ...recipe.ingredients[0], ingredientId: null, unitId: null },
|
{ ...recipe.ingredients[0], ingredientId: null, unitId: null },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives every step an empty sequence when there are no mappings at all", () => {
|
it("gives every step an empty sequence when nothing in it means a known technique", async () => {
|
||||||
const recipe = buildParsedRecipe(["Faire mijoter à feu doux", "Préchauffer le four"]);
|
const recipe = buildParsedRecipe([
|
||||||
|
"Servir immédiatement",
|
||||||
|
"Ranger les couverts dans le tiroir",
|
||||||
|
]);
|
||||||
|
|
||||||
const translated = translateRecipeSteps(recipe, []);
|
const translated = await translateRecipeSteps(recipe, "fr");
|
||||||
|
|
||||||
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[], []]);
|
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[], []]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles a recipe with no steps without error", () => {
|
it("handles a recipe with no steps without error", async () => {
|
||||||
const recipe = buildParsedRecipe([]);
|
const recipe = buildParsedRecipe([]);
|
||||||
|
|
||||||
const translated = translateRecipeSteps(recipe, [simmer]);
|
const translated = await translateRecipeSteps(recipe, "fr");
|
||||||
|
|
||||||
expect(translated.steps).to.deep.equal([]);
|
expect(translated.steps).to.deep.equal([]);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { expect } from "chai";
|
import { expect } from "chai";
|
||||||
import { prisma } from "../../src/db/prisma.js";
|
import { prisma } from "../../src/db/prisma.js";
|
||||||
import {
|
import {
|
||||||
loadTechStepMappingRules,
|
|
||||||
matchTechStepSpans,
|
|
||||||
matchTechSteps,
|
|
||||||
normalizeText,
|
normalizeText,
|
||||||
type TechStepMappingRule,
|
splitIntoClauses,
|
||||||
|
type TechniqueCandidate,
|
||||||
|
techStepClassifier,
|
||||||
} from "../../src/lib/recipe-matching/tech-step-matcher.js";
|
} from "../../src/lib/recipe-matching/tech-step-matcher.js";
|
||||||
import { resetDatabase } from "../../test-support/reset-db.js";
|
import { resetDatabase } from "../../test-support/reset-db.js";
|
||||||
|
|
||||||
|
|
@ -28,227 +27,321 @@ describe("tech-step-matcher", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("matchTechSteps", () => {
|
describe("splitIntoClauses", () => {
|
||||||
const simmer: TechStepMappingRule = {
|
// A candidate's own `uid` doesn't matter to the splitting logic itself
|
||||||
techStepId: 1,
|
// (it's opaque, carried through as `anchor`) — kept short and
|
||||||
expression: "\\bmijot(er|ez|e|ant|é)\\b",
|
// arbitrary across these fixtures.
|
||||||
weight: 15,
|
function candidate(uid: string, start: number, end: number): TechniqueCandidate {
|
||||||
};
|
return { uid, start, end };
|
||||||
const cook: TechStepMappingRule = {
|
}
|
||||||
techStepId: 2,
|
|
||||||
expression: "\\bcui(re|sez|sant|sson)\\b|\\bcuit(e|es|s)?\\b",
|
|
||||||
weight: 10,
|
|
||||||
};
|
|
||||||
const bake: TechStepMappingRule = {
|
|
||||||
techStepId: 3,
|
|
||||||
expression:
|
|
||||||
"\\bcuire au four\\b|\\bcuisson au four\\b|\\benfourn(er|ez|é|ée|ées)\\b|\\bau four\\b",
|
|
||||||
weight: 25,
|
|
||||||
};
|
|
||||||
const preheat: TechStepMappingRule = {
|
|
||||||
techStepId: 4,
|
|
||||||
expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b",
|
|
||||||
weight: 20,
|
|
||||||
};
|
|
||||||
const melt: TechStepMappingRule = {
|
|
||||||
techStepId: 5,
|
|
||||||
expression: "\\bfondre\\b|\\bfaire fondre\\b|\\bfaites fondre\\b",
|
|
||||||
weight: 15,
|
|
||||||
};
|
|
||||||
|
|
||||||
it("matches an exact expression", () => {
|
it("returns the whole description as one anchor-less clause when there are no candidates", () => {
|
||||||
expect(matchTechSteps("Faire mijoter à feu doux", [simmer])).to.deep.equal([1]);
|
const text = "Servir immédiatement";
|
||||||
|
const result = splitIntoClauses(text, []);
|
||||||
|
expect(result).to.deep.equal([{ start: 0, end: text.length, anchor: null }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is case- and accent-insensitive, on both the description and the expression itself", () => {
|
it("returns the whole description as one clause anchored on the single candidate", () => {
|
||||||
// `simmer`'s own expression source contains a literal "é" — exercises
|
const melt = candidate("melt", 6, 13);
|
||||||
// normalizeText being applied to the expression, not just the description.
|
const text = "Faire fondre le beurre";
|
||||||
expect(matchTechSteps("FAIRE MIJOTER", [simmer])).to.deep.equal([1]);
|
const result = splitIntoClauses(text, [melt]);
|
||||||
expect(matchTechSteps("faire mijote", [simmer])).to.deep.equal([1]);
|
expect(result).to.deep.equal([{ start: 0, end: text.length, anchor: melt }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty sequence when nothing matches", () => {
|
it("splits into two clauses at the whitespace nearest the gap's midpoint between two candidates", () => {
|
||||||
expect(matchTechSteps("Servir immédiatement", [simmer, cook, bake])).to.deep.equal([]);
|
// "Préchauffer la poêle, puis faire fondre le beurre"
|
||||||
});
|
// 0 1 2 3 4
|
||||||
|
// 0123456789012345678901234567890123456789012345678901
|
||||||
it("returns an empty sequence for an empty mappings list", () => {
|
const preheat = candidate("preheat", 0, 11); // "Préchauffer"
|
||||||
expect(matchTechSteps("Faire mijoter à feu doux", [])).to.deep.equal([]);
|
const melt = candidate("melt", 27, 39); // "faire fondre"
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty sequence for an empty description", () => {
|
|
||||||
expect(matchTechSteps("", [simmer, cook, bake])).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects several distinct, non-overlapping techniques as an ordered sequence", () => {
|
|
||||||
// The motivating case: "Dans une poêle chaude, faire chauffer une noix
|
|
||||||
// de beurre" involves both preheating and melting — a step can name
|
|
||||||
// more than one technique, in the order they're mentioned.
|
|
||||||
expect(
|
|
||||||
matchTechSteps("Préchauffer la poêle, puis faire fondre le beurre", [preheat, melt]),
|
|
||||||
).to.deep.equal([4, 5]);
|
|
||||||
// Order in the output follows order of mention in the text, not
|
|
||||||
// argument order.
|
|
||||||
expect(
|
|
||||||
matchTechSteps("Préchauffer la poêle, puis faire fondre le beurre", [melt, preheat]),
|
|
||||||
).to.deep.equal([4, 5]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reverses the sequence when the techniques are mentioned in the opposite order", () => {
|
|
||||||
expect(
|
|
||||||
matchTechSteps("Faire fondre le beurre puis préchauffer le four", [preheat, melt]),
|
|
||||||
).to.deep.equal([5, 4]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps only the highest-weight technique when two different techniques' expressions overlap the same words", () => {
|
|
||||||
// "Cuire au four" matches both `cook` (weight 10) and `bake` (weight
|
|
||||||
// 25) at essentially the same span — only the more specific `bake`
|
|
||||||
// should survive, not both.
|
|
||||||
expect(matchTechSteps("Cuire au four pendant 30 minutes", [cook, bake])).to.deep.equal([3]);
|
|
||||||
// Order-independent.
|
|
||||||
expect(matchTechSteps("Cuire au four pendant 30 minutes", [bake, cook])).to.deep.equal([3]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still keeps a non-overlapping technique alongside an overlap-resolved one", () => {
|
|
||||||
// `bake` wins over `cook` for "cuire au four" (overlap), but `melt`
|
|
||||||
// matches an entirely different, non-overlapping span and survives.
|
|
||||||
const result = matchTechSteps("Faire fondre le beurre, puis cuire au four", [
|
|
||||||
cook,
|
|
||||||
bake,
|
|
||||||
melt,
|
|
||||||
]);
|
|
||||||
expect(result).to.deep.equal([5, 3]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("breaks a same-span weight tie by lowest techStepId", () => {
|
|
||||||
const a: TechStepMappingRule = { techStepId: 5, expression: "\\bmelanger\\b", weight: 10 };
|
|
||||||
const b: TechStepMappingRule = { techStepId: 2, expression: "\\bmelanger\\b", weight: 10 };
|
|
||||||
expect(matchTechSteps("Mélanger les ingrédients", [a, b])).to.deep.equal([2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still resolves to one techStep when two of its own mappings both match", () => {
|
|
||||||
const wholeWord: TechStepMappingRule = {
|
|
||||||
techStepId: 7,
|
|
||||||
expression: "\\bmijoter\\b",
|
|
||||||
weight: 15,
|
|
||||||
};
|
|
||||||
const withAdverb: TechStepMappingRule = {
|
|
||||||
techStepId: 7,
|
|
||||||
expression: "\\bmijoter à feu doux\\b",
|
|
||||||
weight: 15,
|
|
||||||
};
|
|
||||||
expect(matchTechSteps("Faire mijoter à feu doux", [wholeWord, withAdverb])).to.deep.equal([
|
|
||||||
7,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects word boundaries — a technique's verb embedded in a longer word doesn't false-positive", () => {
|
|
||||||
// "recuire"/"précuit" contain "cuire"/"cuit" as a substring, but not as
|
|
||||||
// a standalone word — the \b-anchored expression must not match them.
|
|
||||||
expect(matchTechSteps("Faire recuire la sauce", [cook])).to.deep.equal([]);
|
|
||||||
expect(matchTechSteps("Un plat précuit", [cook])).to.deep.equal([]);
|
|
||||||
// The standalone forms still match.
|
|
||||||
expect(matchTechSteps("Faire cuire la sauce", [cook])).to.deep.equal([2]);
|
|
||||||
expect(matchTechSteps("Le riz est cuit", [cook])).to.deep.equal([2]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("matchTechStepSpans", () => {
|
|
||||||
// Same fixtures as `matchTechSteps` above (kept local to this describe
|
|
||||||
// block rather than shared — each block's fixtures should be readable
|
|
||||||
// on their own).
|
|
||||||
const simmer: TechStepMappingRule = {
|
|
||||||
techStepId: 1,
|
|
||||||
expression: "\\bmijot(er|ez|e|ant|é)\\b",
|
|
||||||
weight: 15,
|
|
||||||
};
|
|
||||||
const cook: TechStepMappingRule = {
|
|
||||||
techStepId: 2,
|
|
||||||
expression: "\\bcui(re|sez|sant|sson)\\b|\\bcuit(e|es|s)?\\b",
|
|
||||||
weight: 10,
|
|
||||||
};
|
|
||||||
const bake: TechStepMappingRule = {
|
|
||||||
techStepId: 3,
|
|
||||||
expression:
|
|
||||||
"\\bcuire au four\\b|\\bcuisson au four\\b|\\benfourn(er|ez|é|ée|ées)\\b|\\bau four\\b",
|
|
||||||
weight: 25,
|
|
||||||
};
|
|
||||||
const preheat: TechStepMappingRule = {
|
|
||||||
techStepId: 4,
|
|
||||||
expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b",
|
|
||||||
weight: 20,
|
|
||||||
};
|
|
||||||
const melt: TechStepMappingRule = {
|
|
||||||
techStepId: 5,
|
|
||||||
expression: "\\bfondre\\b|\\bfaire fondre\\b|\\bfaites fondre\\b",
|
|
||||||
weight: 15,
|
|
||||||
};
|
|
||||||
|
|
||||||
it("returns the matched span alongside the techStepId for a simple match", () => {
|
|
||||||
// "Faire mijoter à feu doux" — "mijoter" starts right after "Faire ".
|
|
||||||
expect(matchTechStepSpans("Faire mijoter à feu doux", [simmer])).to.deep.equal([
|
|
||||||
{ techStepId: 1, start: 6, end: 13 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty list when nothing matches", () => {
|
|
||||||
expect(matchTechStepSpans("Servir immédiatement", [simmer, cook, bake])).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns each distinct technique's own span, in reading order", () => {
|
|
||||||
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 = matchTechStepSpans(text, [preheat, melt]);
|
|
||||||
|
const result = splitIntoClauses(text, [preheat, melt]);
|
||||||
|
|
||||||
expect(result).to.have.length(2);
|
expect(result).to.have.length(2);
|
||||||
expect(result[0].techStepId).to.equal(4);
|
// The gap between the two candidates is [11, 27) — its raw midpoint
|
||||||
expect(result[1].techStepId).to.equal(5);
|
// (19) falls inside "poêle" (see findGapSplitPoint's doc comment for
|
||||||
// Each span, sliced back out of the original text, is exactly the
|
// why that's specifically what this snaps away from); the nearest
|
||||||
// word(s) that triggered that match — what the frontend needs to
|
// actual whitespace to that midpoint is the space at 21, right after
|
||||||
// highlight the right characters.
|
// the comma.
|
||||||
expect(text.slice(result[0].start, result[0].end).toLowerCase()).to.equal("préchauffer");
|
expect(result[0]).to.deep.equal({ start: 0, end: 21, anchor: preheat });
|
||||||
expect(text.slice(result[1].start, result[1].end).toLowerCase()).to.equal("faire fondre");
|
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),
|
||||||
|
).to.equal(text);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps only the winning span when two techniques' expressions overlap", () => {
|
it("sorts out-of-order candidates before splitting, and anchors each clause on the matching one", () => {
|
||||||
// `bake` (weight 25) wins over `cook` (weight 10) for "cuire au four"
|
const preheat = candidate("preheat", 0, 11);
|
||||||
// — only bake's span survives, not two overlapping entries.
|
const melt = candidate("melt", 27, 39);
|
||||||
const text = "Cuire au four pendant 30 minutes";
|
// Passed in reverse — the function must still produce clauses in
|
||||||
const result = matchTechStepSpans(text, [cook, bake]);
|
// reading order, each anchored on the right candidate.
|
||||||
expect(result).to.deep.equal([{ techStepId: 3, start: 0, end: 13 }]);
|
const result = splitIntoClauses("Préchauffer la poêle, puis faire fondre le beurre", [
|
||||||
expect(text.slice(0, 13)).to.equal("Cuire au four");
|
melt,
|
||||||
|
preheat,
|
||||||
|
]);
|
||||||
|
expect(result.map((clause) => clause.anchor?.uid)).to.deep.equal(["preheat", "melt"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces N contiguous clauses for N candidates, each anchored on its own", () => {
|
||||||
|
const a = candidate("a", 0, 3);
|
||||||
|
const b = candidate("b", 10, 13);
|
||||||
|
const c = candidate("c", 20, 23);
|
||||||
|
const text = "x".repeat(30);
|
||||||
|
|
||||||
|
const result = splitIntoClauses(text, [a, b, c]);
|
||||||
|
|
||||||
|
expect(result).to.have.length(3);
|
||||||
|
expect(result.map((clause) => clause.anchor?.uid)).to.deep.equal(["a", "b", "c"]);
|
||||||
|
// Contiguous: each clause's end is the next one's start.
|
||||||
|
expect(result[0].start).to.equal(0);
|
||||||
|
expect(result[0].end).to.equal(result[1].start);
|
||||||
|
expect(result[1].end).to.equal(result[2].start);
|
||||||
|
expect(result[2].end).to.equal(text.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps the split point to the earlier candidate's own end when two candidates are adjacent/overlapping", () => {
|
||||||
|
// Gap midpoint would fall *before* `a`'s own end here — must not
|
||||||
|
// produce a clause that cuts into `a`'s own anchor span.
|
||||||
|
const a = candidate("a", 0, 10);
|
||||||
|
const b = candidate("b", 8, 15);
|
||||||
|
|
||||||
|
const result = splitIntoClauses("x".repeat(20), [a, b]);
|
||||||
|
|
||||||
|
expect(result[0].end).to.be.at.least(a.end);
|
||||||
|
expect(result[1].start).to.equal(result[0].end);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loadTechStepMappingRules", () => {
|
describe("techStepClassifier", () => {
|
||||||
|
// `techStepClassifier` is the one shared singleton (see
|
||||||
|
// tech-step-matcher.ts's own doc comment on why) — these tests
|
||||||
|
// exercise it against the real training corpus
|
||||||
|
// (`tech-step-training-data.ts`) and the real seeded `TechStep`
|
||||||
|
// catalog, rather than synthetic injectable fixtures the old
|
||||||
|
// regex-based `matchTechStepSpans(description, mappings)` allowed.
|
||||||
|
// Training + node-nlp's own one-time per-language setup can take a
|
||||||
|
// few seconds on the very first call in the whole suite (subsequent
|
||||||
|
// calls reuse the same trained model and are fast) — comfortably
|
||||||
|
// inside this suite's default 10s timeout (.mocharc.json).
|
||||||
|
let simmerId: number;
|
||||||
|
let cookId: number;
|
||||||
|
let bakeId: number;
|
||||||
|
let preheatId: number;
|
||||||
|
let meltId: number;
|
||||||
|
let boilId: number;
|
||||||
|
let chopId: number;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await resetDatabase();
|
await resetDatabase();
|
||||||
|
const [simmer, cook, bake, preheat, melt, boil, chop] = await Promise.all([
|
||||||
|
prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } }),
|
||||||
|
prisma.techStep.findFirstOrThrow({ where: { key: "cook" } }),
|
||||||
|
prisma.techStep.findFirstOrThrow({ where: { key: "bake" } }),
|
||||||
|
prisma.techStep.findFirstOrThrow({ where: { key: "preheat" } }),
|
||||||
|
prisma.techStep.findFirstOrThrow({ where: { key: "melt" } }),
|
||||||
|
prisma.techStep.findFirstOrThrow({ where: { key: "boil" } }),
|
||||||
|
prisma.techStep.findFirstOrThrow({ where: { key: "chop" } }),
|
||||||
|
]);
|
||||||
|
simmerId = simmer.id;
|
||||||
|
cookId = cook.id;
|
||||||
|
bakeId = bake.id;
|
||||||
|
preheatId = preheat.id;
|
||||||
|
meltId = melt.id;
|
||||||
|
boilId = boil.id;
|
||||||
|
chopId = chop.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only returns mappings for the requested locale", async () => {
|
describe("matchTechSteps", () => {
|
||||||
const simmer = await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } });
|
it("matches an exact expression", async () => {
|
||||||
// "de" has no seeded mappings at all (unlike "fr"/"en", which the
|
expect(
|
||||||
// real catalog now both populate) — a clean locale to attach one
|
await techStepClassifier.matchTechSteps("Faire mijoter à feu doux", "fr"),
|
||||||
// synthetic row to without conflating it with real seed data.
|
).to.deep.equal([simmerId]);
|
||||||
await prisma.techStepMapping.create({
|
|
||||||
data: { techStepId: simmer.id, locale: "de", expression: "\\bsimmer\\b", weight: 15 },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// The seeded catalog (26 "fr" mappings) must be untouched by the extra
|
it("is case- and accent-insensitive", async () => {
|
||||||
// "de" row — same count, and none of them carry its expression.
|
expect(await techStepClassifier.matchTechSteps("FAIRE MIJOTER", "fr")).to.deep.equal([
|
||||||
const frRules = await loadTechStepMappingRules("fr");
|
simmerId,
|
||||||
expect(frRules).to.have.length(26);
|
]);
|
||||||
expect(frRules.map((rule) => rule.expression)).to.not.include("\\bsimmer\\b");
|
});
|
||||||
|
|
||||||
const deRules = await loadTechStepMappingRules("de");
|
it("returns an empty sequence when nothing matches", async () => {
|
||||||
expect(deRules).to.deep.equal([
|
expect(
|
||||||
{ techStepId: simmer.id, expression: "\\bsimmer\\b", weight: 15 },
|
await techStepClassifier.matchTechSteps("Ranger les couverts dans le tiroir", "fr"),
|
||||||
]);
|
).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty sequence for an empty description", async () => {
|
||||||
|
expect(await techStepClassifier.matchTechSteps("", "fr")).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty sequence for a locale nothing was trained on", async () => {
|
||||||
|
expect(
|
||||||
|
await techStepClassifier.matchTechSteps("Faire mijoter à feu doux", "de"),
|
||||||
|
).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects several distinct techniques in one step, in reading order", async () => {
|
||||||
|
expect(
|
||||||
|
await techStepClassifier.matchTechSteps(
|
||||||
|
"Préchauffer la poêle, puis faire fondre le beurre",
|
||||||
|
"fr",
|
||||||
|
),
|
||||||
|
).to.deep.equal([preheatId, meltId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverses the sequence when the techniques are mentioned in the opposite order", async () => {
|
||||||
|
expect(
|
||||||
|
await techStepClassifier.matchTechSteps(
|
||||||
|
"Faire fondre le beurre puis préchauffer le four",
|
||||||
|
"fr",
|
||||||
|
),
|
||||||
|
).to.deep.equal([meltId, preheatId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still matches the generic technique on its own when the more specific one isn't implied", async () => {
|
||||||
|
expect(
|
||||||
|
await techStepClassifier.matchTechSteps("Faire cuire à feu moyen", "fr"),
|
||||||
|
).to.deep.equal([cookId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the more specific technique when a generic one's own vocabulary is embedded in it", async () => {
|
||||||
|
// "Cuire au four" literally contains "cuire" (the generic `cook`
|
||||||
|
// verb) but means the more specific `bake` — the classifier (not
|
||||||
|
// a weight table) is what has to get this right now.
|
||||||
|
expect(
|
||||||
|
await techStepClassifier.matchTechSteps("Cuire au four pendant 30 minutes", "fr"),
|
||||||
|
).to.deep.equal([bakeId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("understands a technique described without ever naming it — the whole point of moving off pure keyword matching", async () => {
|
||||||
|
// No literal "fondre"/"fondu" anywhere in this sentence, yet it
|
||||||
|
// unambiguously means `melt` — this is the exact motivating case
|
||||||
|
// (see this module's own doc comment) a regex could never catch.
|
||||||
|
expect(
|
||||||
|
await techStepClassifier.matchTechSteps(
|
||||||
|
"jusqu'à ce que le beurre ait disparu dans la poêle",
|
||||||
|
"fr",
|
||||||
|
),
|
||||||
|
).to.deep.equal([meltId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("understands preheating described without the verb 'préchauffer'", async () => {
|
||||||
|
expect(
|
||||||
|
await techStepClassifier.matchTechSteps("mettre la poêle sur feu vif", "fr"),
|
||||||
|
).to.deep.equal([preheatId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches English text against the English-trained vocabulary", async () => {
|
||||||
|
expect(
|
||||||
|
await techStepClassifier.matchTechSteps(
|
||||||
|
"Bring a large saucepan of salted water to the boil",
|
||||||
|
"en",
|
||||||
|
),
|
||||||
|
).to.deep.equal([boilId]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty list for a locale with no mappings at all", async () => {
|
describe("matchTechStepSpans", () => {
|
||||||
expect(await loadTechStepMappingRules("de")).to.deep.equal([]);
|
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, contextStart: 0, contextEnd: text.length },
|
||||||
|
]);
|
||||||
|
expect(text.slice(6, 13).toLowerCase()).to.equal("mijoter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when nothing matches", async () => {
|
||||||
|
expect(
|
||||||
|
await techStepClassifier.matchTechStepSpans("Ranger les couverts dans le tiroir", "fr"),
|
||||||
|
).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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("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,
|
||||||
|
contextStart: 0,
|
||||||
|
contextEnd: text.length,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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, contextStart: 0, contextEnd: text.length },
|
||||||
|
]);
|
||||||
|
expect(text.slice(0, 4)).to.equal("Chop");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -149,15 +149,13 @@ describe("Reference data", () => {
|
||||||
expect(keys).to.deep.equal([...keys].sort());
|
expect(keys).to.deep.equal([...keys].sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reseeding is idempotent — no duplicate techniques or mappings", async () => {
|
it("reseeding is idempotent — no duplicate techniques", async () => {
|
||||||
// resetDatabase already seeded once in beforeEach; seed a second time
|
// resetDatabase already seeded once in beforeEach; seed a second time
|
||||||
// on top of that without truncating, the way a redeploy would.
|
// on top of that without truncating, the way a redeploy would.
|
||||||
await seedReferenceData(prisma);
|
await seedReferenceData(prisma);
|
||||||
|
|
||||||
const res = await request(app).get("/reference/tech-steps");
|
const res = await request(app).get("/reference/tech-steps");
|
||||||
expect(res.body).to.have.length(26);
|
expect(res.body).to.have.length(26);
|
||||||
// 26 techniques × one "fr" + one "en" mapping each.
|
|
||||||
expect(await prisma.techStepMapping.count()).to.equal(52);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `.step-tech-step-context` (the wider clause a `.step-tech-step` keyword
|
||||||
|
// was found in) used to be highlighted here too, more subtly — turned back
|
||||||
|
// off (see `StepDescription.tsx`'s doc comment): the backend still
|
||||||
|
// computes and persists `contextStart`/`contextEnd`, this file just no
|
||||||
|
// longer gives that class any styling to render with.
|
||||||
|
|
||||||
// --- Favorite star toggle (detail panel header) -----------------------------
|
// --- Favorite star toggle (detail panel header) -----------------------------
|
||||||
.favorite-star-button {
|
.favorite-star-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,17 @@ import { splitDescriptionByTechSteps } from "./highlight-tech-steps";
|
||||||
* 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>`.
|
||||||
*
|
*
|
||||||
|
* A match's wider `contextStart`/`contextEnd` clause (see
|
||||||
|
* `StepTechStepView`) is deliberately *not* visualized here — only the
|
||||||
|
* tight keyword span is highlighted. The backend still computes and
|
||||||
|
* persists it (`tech-step-matcher.ts`/`StepTechStep`), and
|
||||||
|
* `splitDescriptionByTechSteps` still splits the description around it
|
||||||
|
* (`isKeyword: false` context segments), but this component now renders
|
||||||
|
* those non-keyword segments as plain text, same as a segment with no
|
||||||
|
* technique at all — the visual "wider clause, subtler highlight"
|
||||||
|
* treatment (`.step-tech-step-context`) turned out to be more visual noise
|
||||||
|
* than useful signal in practice and was turned back off.
|
||||||
|
*
|
||||||
* `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, …)
|
||||||
* uses for its display text.
|
* uses for its display text.
|
||||||
|
|
@ -34,6 +45,13 @@ 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 — rendered as plain text, same as a segment
|
||||||
|
// with no technique at all (see this component's doc comment for
|
||||||
|
// why the wider-clause highlight was turned back off).
|
||||||
|
return <Fragment key={key}>{segment.text}</Fragment>;
|
||||||
|
}
|
||||||
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,89 @@
|
||||||
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"). `StepDescription.tsx`
|
||||||
|
* currently renders `isKeyword: false` segments as plain text (no visual
|
||||||
|
* distinction from a segment with no technique at all) — the context split
|
||||||
|
* still happens here so the data stays available, but its own dedicated
|
||||||
|
* highlight was turned back off; see that component's doc comment.
|
||||||
*/
|
*/
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
696
pnpm-lock.yaml
696
pnpm-lock.yaml
|
|
@ -44,6 +44,9 @@ importers:
|
||||||
jsonwebtoken:
|
jsonwebtoken:
|
||||||
specifier: ^9.0.3
|
specifier: ^9.0.3
|
||||||
version: 9.0.3
|
version: 9.0.3
|
||||||
|
node-nlp:
|
||||||
|
specifier: 4.27.0
|
||||||
|
version: 4.27.0
|
||||||
prisma:
|
prisma:
|
||||||
specifier: ^5.22.0
|
specifier: ^5.22.0
|
||||||
version: 5.22.0
|
version: 5.22.0
|
||||||
|
|
@ -850,12 +853,224 @@ packages:
|
||||||
resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==, tarball: https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz}
|
resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==, tarball: https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-choice@1.3.1':
|
||||||
|
resolution: {integrity: sha512-HubunMJVq/OetmdvcAmBh5skMlg+yiScm3V2wNyNZIVvLgli4+8nzbg/W/fI9dpaf6wv9ZQ7d2IYvn8swJBo3A==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-choice/-/recognizers-text-choice-1.3.1.tgz}
|
||||||
|
engines: {node: '>=10.3.0'}
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-data-types-timex-expression@1.3.1':
|
||||||
|
resolution: {integrity: sha512-jarJIFIJZBqeofy3hh0vdQo1yOmTM+jCjj6/zmo9JunsQ6LO750eZHCg9eLptQhsvq321XCt5xdRNLCwU8YeNA==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-data-types-timex-expression/-/recognizers-text-data-types-timex-expression-1.3.1.tgz}
|
||||||
|
engines: {node: '>=10.3.0'}
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-date-time@1.3.2':
|
||||||
|
resolution: {integrity: sha512-fUEGOTccS55ZY0erzjS1bunJYA9lGXjcZoru5oPOlnxbJS4Lk0ylgdH2Ub2EjAyqr8DIJhdLNOEesCdAXMvlNg==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-date-time/-/recognizers-text-date-time-1.3.2.tgz}
|
||||||
|
engines: {node: '>=10.3.0'}
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-number-with-unit@1.3.1':
|
||||||
|
resolution: {integrity: sha512-gzCpPP4zQ5Vb+RHaWjzP2t1c+mj6GYOsFoI2NyJkm8OZ52XI+x9SJCgrrD2ujzjOd5/CQVC46rE22rfGwXLDkA==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-number-with-unit/-/recognizers-text-number-with-unit-1.3.1.tgz}
|
||||||
|
engines: {node: '>=10.3.0'}
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-number@1.3.1':
|
||||||
|
resolution: {integrity: sha512-JBxhSdihdQLQilCtqISEBw5kM+CNGTXzy5j5hNoZECNUEvBUPkAGNEJAeQPMP5abrYks29aSklnSvSyLObXaNQ==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-number/-/recognizers-text-number-1.3.1.tgz}
|
||||||
|
engines: {node: '>=10.3.0'}
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-sequence@1.3.1':
|
||||||
|
resolution: {integrity: sha512-J7Kg35hpm0NcFHmu69Bb4q7DPDiSpCd8ApUZqNm59itIjrQJHpSdl9HF6JxuQQz0Ftc/li5ZLqSuupJAmA/sgg==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-sequence/-/recognizers-text-sequence-1.3.1.tgz}
|
||||||
|
engines: {node: '>=10.3.0'}
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-suite@1.3.0':
|
||||||
|
resolution: {integrity: sha512-uqG4vzy5N2CmBaeINny0bLdnGp0jDbT1moNoLC+Yim3G8kHOU9lpDfwA6VN6HTYaDM5854SNMEzLjJdS1TPFTw==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-suite/-/recognizers-text-suite-1.3.0.tgz}
|
||||||
|
engines: {node: '>=10.3.0'}
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text@1.3.1':
|
||||||
|
resolution: {integrity: sha512-HikLoRUgSzM4OKP3JVBzUUp3Q7L4wgI17p/3rERF01HVmopcujY3i6wgx8PenCwbenyTNxjr1AwSDSVuFlYedQ==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text/-/recognizers-text-1.3.1.tgz}
|
||||||
|
engines: {node: '>=10.3.0'}
|
||||||
|
|
||||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==, tarball: https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz}
|
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==, tarball: https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz}
|
||||||
engines: {node: ^22.20 || ^24.12 || >=25}
|
engines: {node: ^22.20 || ^24.12 || >=25}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@nlpjs/builtin-duckling@4.26.1':
|
||||||
|
resolution: {integrity: sha512-3qkH955X2g5MXV1EqT3fTAT/lLEdiqqe5IgBDyr+MQB7FOV9R3YhqGIn3DFOl+TSm/tP5n/BAEptkTNn/TOpmQ==, tarball: https://registry.npmjs.org/@nlpjs/builtin-duckling/-/builtin-duckling-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/builtin-microsoft@4.26.1':
|
||||||
|
resolution: {integrity: sha512-AODgzTcfYUf5Ozm00aQnHImDum7Idtl0F9dSPoaXpfj7rZqP8hPZ7iWwdGTAvISH/da2YhjPOU65QSYk2YpjFA==, tarball: https://registry.npmjs.org/@nlpjs/builtin-microsoft/-/builtin-microsoft-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/core-loader@4.26.1':
|
||||||
|
resolution: {integrity: sha512-IiRtn65bdiUSQHy2kusco2fmhk39u2Mc2c5Fsm9+9EVG6BtJCmVEFU/btAzGDAmxEA/E4qKecaAT4LvcW6TPbA==, tarball: https://registry.npmjs.org/@nlpjs/core-loader/-/core-loader-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/core@4.26.1':
|
||||||
|
resolution: {integrity: sha512-M/PeFddsi3y7Z1piFJxsLGm5/xdMhcrpOsml7s6CTEgYo8iduaT30HDd61tZxDyvvJseU6uFqlXSn7XKkAcC1g==, tarball: https://registry.npmjs.org/@nlpjs/core/-/core-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/emoji@4.26.1':
|
||||||
|
resolution: {integrity: sha512-Q0PoXwIvaB1bnRXK4U/YD7mrqaz29Yfed3s2au0iXl1bffUgoG+hs4GORCvyy7DFCCLlc9d5yDM3oLIX/ggZ+Q==, tarball: https://registry.npmjs.org/@nlpjs/emoji/-/emoji-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/evaluator@4.26.1':
|
||||||
|
resolution: {integrity: sha512-WeUrC8qq7+V8Jhkkjc2yiXdzy9V0wbETv8/qasQmL0QmEuwBDJF+fvfl4z2vWpBb0vW07A8aNrFElKELzbpkdg==, tarball: https://registry.npmjs.org/@nlpjs/evaluator/-/evaluator-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-all@4.26.1':
|
||||||
|
resolution: {integrity: sha512-UzRm1JRRAyQqilEOxQ2ySMOitKbhPk5iKYbjD8FREDcPjreUvDxVuQsYUOvYucmEyFcZU2U/TdJx+fX9/bcaKQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-all/-/lang-all-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ar@4.26.1':
|
||||||
|
resolution: {integrity: sha512-MUlVtabt9ltG7WyzCQpFJymLJlnEqp3mxhgN9JHyFH7oZMK3REvMovFfvEUAbfiYrJEv/BN5KKLL7yrvUeaHtg==, tarball: https://registry.npmjs.org/@nlpjs/lang-ar/-/lang-ar-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-bn@4.26.1':
|
||||||
|
resolution: {integrity: sha512-sim1iZKBDdehi/yBUKrLW51QvS9uB+sXW7lj+THVqBy5UsnEQvt4gzE0NsC873uJMh66vt2AlHkhzgPH0qH/nQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-bn/-/lang-bn-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ca@4.26.1':
|
||||||
|
resolution: {integrity: sha512-fD4R5tcAB0uYtNxSEF20b1KmF6nUQSbiJqrIUJI5yis4ObjCYRQnSh4bjVDKUKxyONjbD6L8EaK5GrY1/jkwFQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-ca/-/lang-ca-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-cs@4.26.1':
|
||||||
|
resolution: {integrity: sha512-CqI6VB8toaJ/MlP1D4K9BctA6GpZJhMKyEy+OX9xavDe4r4ao/SxlSaIYK3izK0k+J38lJWC5lXYGazfCdTGjA==, tarball: https://registry.npmjs.org/@nlpjs/lang-cs/-/lang-cs-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-da@4.26.1':
|
||||||
|
resolution: {integrity: sha512-krI/ojeDSi329ENM/hLIsbUh1x4XRTKAbtPcbFxAY6XVhcSVoWPO7L77jFTL1NQeE1oGRFzGHaeC9hZJ8phVbA==, tarball: https://registry.npmjs.org/@nlpjs/lang-da/-/lang-da-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-de@4.26.1':
|
||||||
|
resolution: {integrity: sha512-HfZQwsE5FICq9taVZDiyktmdAePVF5948NM80et0d9mx43RWDFhHKQYgtJPwfQXtdCoQtOM5TOJ2FanGwzPeaA==, tarball: https://registry.npmjs.org/@nlpjs/lang-de/-/lang-de-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-el@4.26.1':
|
||||||
|
resolution: {integrity: sha512-pcOvuSwPCXxI+2xNZZzM4V5pTRDntYoJi0SP/ic2nV4IPQ0nU2j16dYfg1HlvET/E6iN1VTqghrCaf10SMkDGA==, tarball: https://registry.npmjs.org/@nlpjs/lang-el/-/lang-el-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-en-min@4.26.1':
|
||||||
|
resolution: {integrity: sha512-1sJZ7dy7ysqzbsB8IklguvB88J8EPIv4XGVkZCcwecKtOw+fp5LAsZ3TJVmEf18iK1gD4cEGr7qZg5fpPxTpWQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-en-min/-/lang-en-min-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-en@4.26.1':
|
||||||
|
resolution: {integrity: sha512-GVoJpOjyk5TtBAqo/fxsiuuH7jXycyakGT0gw5f01u9lOmUnpJegvXyGff/Nb0j14pXcGHXOhmpWrcTrG2B0LQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-en/-/lang-en-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-es@4.26.1':
|
||||||
|
resolution: {integrity: sha512-fIPQt+WPcNdyxZOCMkOPlMb4Y1iE585QxjB9IAdFz8ZtVg7mc4dlv5f46ud7ppdMh84iLOuOdo6pzu2Cqm14lw==, tarball: https://registry.npmjs.org/@nlpjs/lang-es/-/lang-es-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-eu@4.26.1':
|
||||||
|
resolution: {integrity: sha512-Ha8GHTbgQYd7dwHM8aWHDyxmbUNUcyu/5xlBKqqBOPxysDyZ6Ad0tvj0FmJBy6mYhqmFTPBnEAo69cfuFSqWIQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-eu/-/lang-eu-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-fa@4.26.1':
|
||||||
|
resolution: {integrity: sha512-qJCmNXgJZnfNXUnKnxvEGEzSFBdQT4XU7/rMxuFmSJqmQY7fH/Vsmi5CKF94VRBPOIV4ULlEJuLpUWHXRmOnVQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-fa/-/lang-fa-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-fi@4.26.1':
|
||||||
|
resolution: {integrity: sha512-W/rUcrzSh3KE07q2vOsssTpU1sbX32gbBzKPZfRJ2ZUF4afO+eHxmAywikXubP4kiU3JxVNLvXXEjuGD3SBUbA==, tarball: https://registry.npmjs.org/@nlpjs/lang-fi/-/lang-fi-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-fr@4.26.1':
|
||||||
|
resolution: {integrity: sha512-LTA852atCJnHtKDmtjx/ui5AnvEIkrPx+MJQ2mB3gn8ko6i2UITnJgPmJE9Kej5bLasVZOAJvU/SrfXEmnPGOw==, tarball: https://registry.npmjs.org/@nlpjs/lang-fr/-/lang-fr-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ga@4.26.1':
|
||||||
|
resolution: {integrity: sha512-JsP1CZ8r3Jd6o/Az7cN3exz0HDP3FNYLzh4Vi6ksEkdKF0yCjJ9G5dXZYqS9qFIN5ffemWn29G4WRELY6QH/cQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-ga/-/lang-ga-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-gl@4.26.1':
|
||||||
|
resolution: {integrity: sha512-y1NNu6NVy/6o5UNfihgg0WkSlVr4IvKA5W193CpRLZWS4FccQDmnFFhyYWRkshyDbgEsfsZ0Rs3BoE82+T2Ubg==, tarball: https://registry.npmjs.org/@nlpjs/lang-gl/-/lang-gl-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-hi@4.26.1':
|
||||||
|
resolution: {integrity: sha512-Fw9rXqF5l8q9etJG5uOlEFpnMVjQEWMaCIgQfEcA1yTvieSV8mpoSvQkEZl+DFhww+azareoJ7ZCkx0gJ9UDuQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-hi/-/lang-hi-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-hu@4.26.1':
|
||||||
|
resolution: {integrity: sha512-7dPUn5/ZpLZmsdRwO+dtORuMIiIpnsWbgSLIKdOLh8irhgUR+M2bYTfkdnKcrEcHzHPP8Svn7pU0xk7OKSUA1w==, tarball: https://registry.npmjs.org/@nlpjs/lang-hu/-/lang-hu-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-hy@4.26.1':
|
||||||
|
resolution: {integrity: sha512-T2brpLGDJryAwWmjtnmY8Ot6ZUkCz+/nRR9/QM1PybvZIqOVLjJqA49bqjJfT5DMN89HbwC7I/15NTT0y09i1Q==, tarball: https://registry.npmjs.org/@nlpjs/lang-hy/-/lang-hy-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-id@4.26.1':
|
||||||
|
resolution: {integrity: sha512-rVuIkYFKdltFhMT/a2ZxD9ovoZSVZF7OPuqYjTXW9xKd3Ff32yUrzcf/pHXlqmZOSltqOH3E5jZRRDkHvgUOjQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-id/-/lang-id-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-it@4.26.1':
|
||||||
|
resolution: {integrity: sha512-BZA3QnfQGW91gYaybRmHnCAPBvQggtmHZJrAmuBZUKUS12HoQm8uybjw2fZO+vahEeUQceKNDISRcT1eLLijog==, tarball: https://registry.npmjs.org/@nlpjs/lang-it/-/lang-it-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ja@4.26.1':
|
||||||
|
resolution: {integrity: sha512-QgkuJOkHguRFyfnckH2It5/Kg8zecnOMJsHxYeuDC4tBF7jL/5xqWis+679lYLsXtAkrG8+fjVcBbjyopP0KHg==, tarball: https://registry.npmjs.org/@nlpjs/lang-ja/-/lang-ja-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ko@4.26.1':
|
||||||
|
resolution: {integrity: sha512-Q0N8bLJJ829ILWCKH1UQWPSNyuLaEURAXCawkDju4pt33DBLcpqz9IzO9dnqiFc+fjSgVzZ7WMaLT18hXZQ9vg==, tarball: https://registry.npmjs.org/@nlpjs/lang-ko/-/lang-ko-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-lt@4.26.1':
|
||||||
|
resolution: {integrity: sha512-SeYZxRhdCy+ClQNnF/u0MAtcDui/ocdk4NtgNOCuwNTNuzhN3t3rfGeArfBGmZeg1SIeBLUDE9dsTxYCv5AOEg==, tarball: https://registry.npmjs.org/@nlpjs/lang-lt/-/lang-lt-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ms@4.26.1':
|
||||||
|
resolution: {integrity: sha512-KxWBS+tFY2U8z9UrjQIqMM40npGDOskP5DcWhaEE3zuhzf3RTDYjy8sdz34jVd0fBdbPihX133h3bFibg2Cm7w==, tarball: https://registry.npmjs.org/@nlpjs/lang-ms/-/lang-ms-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ne@4.26.1':
|
||||||
|
resolution: {integrity: sha512-K3E2l+0LTESv+dO+ZTIdvNa+zwMJvvnMiFYYkKvJst6lhc8JgvGOsPxGsjJn6PDhI3wyfQu+dg3b+bnVPu4FDA==, tarball: https://registry.npmjs.org/@nlpjs/lang-ne/-/lang-ne-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-nl@4.26.1':
|
||||||
|
resolution: {integrity: sha512-I/mP1RRbUN4BQ+8NXAl2FKaLHbb7f6S8JVjxHQ0sKHT4BgQ3+r0yO+DVcEsHg+vWRiY1Fyzh0gq0PhLVnF6HnA==, tarball: https://registry.npmjs.org/@nlpjs/lang-nl/-/lang-nl-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-no@4.26.1':
|
||||||
|
resolution: {integrity: sha512-a0CLL2c/OCzbg7J7ugyrsAksI96XhkQ3IeBbbx60o5o/9wsFNik6cPWrkpoE5xNtw7gLlAJWabwDiZXkl8Zrcw==, tarball: https://registry.npmjs.org/@nlpjs/lang-no/-/lang-no-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-pl@4.26.1':
|
||||||
|
resolution: {integrity: sha512-nrDXlq+TzQLE5IpXPIlFMzd8OpquvApWsouh6fmLsD9HZLZI4O3w1M4sXXLzE+9Ggu9Cy1m1QJ0/i7XCcv115g==, tarball: https://registry.npmjs.org/@nlpjs/lang-pl/-/lang-pl-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-pt@4.26.1':
|
||||||
|
resolution: {integrity: sha512-p6yZHaJ0e+n0avMHpdDw5PMk4HkKXjPbOMbrlg0dF+VRqChjxfH478Q423rDyzu/4MzDsIYB+p6KzL9AARKXpg==, tarball: https://registry.npmjs.org/@nlpjs/lang-pt/-/lang-pt-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ro@4.26.1':
|
||||||
|
resolution: {integrity: sha512-baUdTA0DWpDR0Tn6fxo+RDN/6gbuINLCARtHwap2UR/HKQWP2XoH/DIvcjZpwUTalr5MQjso31epcdeRRapczA==, tarball: https://registry.npmjs.org/@nlpjs/lang-ro/-/lang-ro-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ru@4.26.1':
|
||||||
|
resolution: {integrity: sha512-NaZ2DAOGxWG2Us9IyIDs3m6vhGpUaUJRVgzzHHyX3LO3xEYjZmtnA0jEpBaTOe2PuNHThv0WCZUNn9BSurV3PA==, tarball: https://registry.npmjs.org/@nlpjs/lang-ru/-/lang-ru-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-sl@4.26.1':
|
||||||
|
resolution: {integrity: sha512-QBJwcJt+oKUpAnHKNJkLkx9Xm1n4dUPC5GPYfAXTnJZf0hNWJSY21GicdWi7Vu/qFJ3ghIqtSP8D7KIPLnibNw==, tarball: https://registry.npmjs.org/@nlpjs/lang-sl/-/lang-sl-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-sr@4.26.1':
|
||||||
|
resolution: {integrity: sha512-drH3+UqTW637uLWsnLrcp8jEKUGxV61ZgCBjNkVQNEv1/jbpSg6IqgynSY2JyhtnlV0f870KS0HvSbyo5AD4Ng==, tarball: https://registry.npmjs.org/@nlpjs/lang-sr/-/lang-sr-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-sv@4.26.1':
|
||||||
|
resolution: {integrity: sha512-2axkrYFC02tAlxCWeiEKISbe4dSteciP1CIggO/dZglnnLWgdF+g7kOeYMn7abCfFVSnh5vLqfDkrwnyIqt7Ag==, tarball: https://registry.npmjs.org/@nlpjs/lang-sv/-/lang-sv-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ta@4.26.1':
|
||||||
|
resolution: {integrity: sha512-keeh+croa1TAirV9Fd3OQMo5IkAlTGNWTNweHbi/htYMX0MKOPYxyqg+VH2bml+57VY2aUj/WYgV/p3ATx9EfQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-ta/-/lang-ta-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-th@4.26.1':
|
||||||
|
resolution: {integrity: sha512-2SWZhrln3rMw8/DsRc9yS5bi3qEdGfw2pq9Uejx/UYED5zvvL6kh9AiCJZT4k0wMBGEwWUV6HxJ0Pq/jOTHogg==, tarball: https://registry.npmjs.org/@nlpjs/lang-th/-/lang-th-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-tl@4.26.1':
|
||||||
|
resolution: {integrity: sha512-AzmLtg28tm0VXCm0Q0EY3OtA3m4oYxaqh4VX6uhB4J+PoEsIkm0py12SJxMNIsh/r98pobCumH8KH9bvHQoCAg==, tarball: https://registry.npmjs.org/@nlpjs/lang-tl/-/lang-tl-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-tr@4.26.1':
|
||||||
|
resolution: {integrity: sha512-p30uuXvE9pZeU/5XkrQfvxRgiAOBmP3EyBFGV/+P05PEogaqbsmmtVCgCnR63yeRvVnGbToPBPjRK3OO1y4AEQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-tr/-/lang-tr-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-uk@4.26.1':
|
||||||
|
resolution: {integrity: sha512-PVEvmlhvl6BL3e/Q4qjMPsnwON3cWEYvDh9dg+Si+sjD2Edu9tajolJKcQ6ZA4I8dXrld5xuXx+DEBH/uB4uWQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-uk/-/lang-uk-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-zh@4.26.1':
|
||||||
|
resolution: {integrity: sha512-kwqeqeEgMAMvucVX9HNE1p6s/2APP23ZsS8Um/lNvtswb4gL5jjYF9kyCvRfqlPBQSWWdRv7wwcnNXOvXYkxcQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-zh/-/lang-zh-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/language-min@4.25.0':
|
||||||
|
resolution: {integrity: sha512-g8jtbDbqtRm+dlD/1Vnb4VWfKbKteApEGVTqIMxYkk6N/HMhvLZ5J2svrxzrB98a/HZ0fb//YBfFgymnz9Oukg==, tarball: https://registry.npmjs.org/@nlpjs/language-min/-/language-min-4.25.0.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/language@4.25.0':
|
||||||
|
resolution: {integrity: sha512-tUF6QENoUQ/E26RYc32IgsttStSF9cNO4ySN+BQECn8VpjukWdwbMw073MlOLXzjfeobxa+3hCVrmPPcW+V3UA==, tarball: https://registry.npmjs.org/@nlpjs/language/-/language-4.25.0.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/ner@4.27.0':
|
||||||
|
resolution: {integrity: sha512-ptwkxriJdmgHSH9TfP10JQ1jviaSl2SupSFGUvTuWkuJhobQd3hbnlSq40V6XYvJNmqh9M9zEab/AKeghxYOTA==, tarball: https://registry.npmjs.org/@nlpjs/ner/-/ner-4.27.0.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/neural@4.25.0':
|
||||||
|
resolution: {integrity: sha512-Oz20denGiBe0DlQsS7lN4TNrATN1nXlHKc/HB6jJPegjVmgJVCugDaHwIGoV7qOWyA6F2fRRwOgD+quNT2gVpg==, tarball: https://registry.npmjs.org/@nlpjs/neural/-/neural-4.25.0.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/nlg@4.26.1':
|
||||||
|
resolution: {integrity: sha512-PCJWiZ7464ChXXUGvjBZIFtoqkC24Oy6X63HgQrSv+63svz22Y5Cmu1MYLk77Nb+4keWv+hKhFJKDkvJoOpBVg==, tarball: https://registry.npmjs.org/@nlpjs/nlg/-/nlg-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/nlp@4.27.0':
|
||||||
|
resolution: {integrity: sha512-q6X7sY6TYVnQRZJKF/6mfLFlNA5oRYLhgQ5k3i1IBqH9lbWTAZJr31w/dCf97HXaYaj+vJp3h0ucfNumme9EIw==, tarball: https://registry.npmjs.org/@nlpjs/nlp/-/nlp-4.27.0.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/nlu@4.27.0':
|
||||||
|
resolution: {integrity: sha512-j4DUdoXS/y/Xag6ysYXx7Ve8NBmUVViUSCJhj3r49+zGyYtyVAHuVcqSej5q0tJjn0JSMT+6+ip8klON1q8ixw==, tarball: https://registry.npmjs.org/@nlpjs/nlu/-/nlu-4.27.0.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/request@4.25.0':
|
||||||
|
resolution: {integrity: sha512-MPVYWfFZY03WyFL7GWkUkv8tw968OXsdxFSJEvjXHzhiCe/vAlPCWbvoR+VnoQTgzLHxs/KIF6sIF2s9AzsLmQ==, tarball: https://registry.npmjs.org/@nlpjs/request/-/request-4.25.0.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/sentiment@4.26.1':
|
||||||
|
resolution: {integrity: sha512-U2WmcW3w6yDDO45+Y7v5e6DPQj8e0x+RUUePPyRu2uIZmUtIKG+qCPMWnNLMmYQZoSQEFxmMMlLcGDC7tN7o3w==, tarball: https://registry.npmjs.org/@nlpjs/sentiment/-/sentiment-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/similarity@4.26.1':
|
||||||
|
resolution: {integrity: sha512-QutSBFGo/huNuz60PgqCjub0oBd9S8MLrjme33U5GzxuSvToQzXtn9/ynIia8qDm009D09VXV+LPeNE4h7yuSg==, tarball: https://registry.npmjs.org/@nlpjs/similarity/-/similarity-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/slot@4.26.1':
|
||||||
|
resolution: {integrity: sha512-mK8EEy5O+mRGne822PIKMxHSFh8j+iC7hGJ6T31XdFsNhFEYXLI/0dmeBstZgTSKBTe27HNFgCCwuGb77u0o9w==, tarball: https://registry.npmjs.org/@nlpjs/slot/-/slot-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/xtables@4.25.0':
|
||||||
|
resolution: {integrity: sha512-+baCtMZIp+aDqODLQs8Wyyke5qUqQkL8AGWsZzwYuJV8S7xdW2+XklRnHnkFc3p3foC248TkzG5L8j9r6INOtg==, tarball: https://registry.npmjs.org/@nlpjs/xtables/-/xtables-4.25.0.tgz}
|
||||||
|
|
||||||
'@noble/hashes@1.8.0':
|
'@noble/hashes@1.8.0':
|
||||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==, tarball: https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz}
|
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==, tarball: https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz}
|
||||||
engines: {node: ^14.21.3 || >=16}
|
engines: {node: ^14.21.3 || >=16}
|
||||||
|
|
@ -1118,6 +1333,10 @@ packages:
|
||||||
resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==, tarball: https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-3.0.0.tgz}
|
resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==, tarball: https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-3.0.0.tgz}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
'@tootallnate/once@2.0.1':
|
||||||
|
resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==, tarball: https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==, tarball: https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz}
|
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==, tarball: https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz}
|
||||||
|
|
||||||
|
|
@ -1287,6 +1506,10 @@ packages:
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
adler-32@1.3.1:
|
||||||
|
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==, tarball: https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
agent-base@6.0.2:
|
agent-base@6.0.2:
|
||||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==, tarball: https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz}
|
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==, tarball: https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz}
|
||||||
engines: {node: '>= 6.0.0'}
|
engines: {node: '>= 6.0.0'}
|
||||||
|
|
@ -1390,6 +1613,9 @@ packages:
|
||||||
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==, tarball: https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz}
|
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==, tarball: https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
async@2.6.4:
|
||||||
|
resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==, tarball: https://registry.npmjs.org/async/-/async-2.6.4.tgz}
|
||||||
|
|
||||||
async@3.2.6:
|
async@3.2.6:
|
||||||
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==, tarball: https://registry.npmjs.org/async/-/async-3.2.6.tgz}
|
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==, tarball: https://registry.npmjs.org/async/-/async-3.2.6.tgz}
|
||||||
|
|
||||||
|
|
@ -1431,6 +1657,9 @@ packages:
|
||||||
bcrypt-pbkdf@1.0.2:
|
bcrypt-pbkdf@1.0.2:
|
||||||
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==, tarball: https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz}
|
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==, tarball: https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz}
|
||||||
|
|
||||||
|
bignumber.js@7.2.1:
|
||||||
|
resolution: {integrity: sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ==, tarball: https://registry.npmjs.org/bignumber.js/-/bignumber.js-7.2.1.tgz}
|
||||||
|
|
||||||
binary-extensions@2.3.0:
|
binary-extensions@2.3.0:
|
||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==, tarball: https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==, tarball: https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
@ -1519,6 +1748,10 @@ packages:
|
||||||
caseless@0.12.0:
|
caseless@0.12.0:
|
||||||
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==, tarball: https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz}
|
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==, tarball: https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz}
|
||||||
|
|
||||||
|
cfb@1.2.2:
|
||||||
|
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==, tarball: https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
chai@5.3.3:
|
chai@5.3.3:
|
||||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==, tarball: https://registry.npmjs.org/chai/-/chai-5.3.3.tgz}
|
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==, tarball: https://registry.npmjs.org/chai/-/chai-5.3.3.tgz}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -1589,6 +1822,10 @@ packages:
|
||||||
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==, tarball: https://registry.npmjs.org/clone/-/clone-1.0.4.tgz}
|
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==, tarball: https://registry.npmjs.org/clone/-/clone-1.0.4.tgz}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
|
codepage@1.15.0:
|
||||||
|
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==, tarball: https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, tarball: https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, tarball: https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
|
|
@ -1703,6 +1940,11 @@ packages:
|
||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
crc-32@1.2.2:
|
||||||
|
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==, tarball: https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
cross-env@10.1.0:
|
cross-env@10.1.0:
|
||||||
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==, tarball: https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz}
|
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==, tarball: https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
@ -1890,6 +2132,9 @@ packages:
|
||||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==, tarball: https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz}
|
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==, tarball: https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
doublearray@0.0.2:
|
||||||
|
resolution: {integrity: sha512-aw55FtZzT6AmiamEj2kvmR6BuFqvYgKZUkfQ7teqVRNqD5UE0rw8IeW/3gieHNKQ5sPuDKlljWEn4bzv5+1bHw==, tarball: https://registry.npmjs.org/doublearray/-/doublearray-0.0.2.tgz}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, tarball: https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, tarball: https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -2156,6 +2401,10 @@ packages:
|
||||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==, tarball: https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz}
|
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==, tarball: https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
frac@1.1.2:
|
||||||
|
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==, tarball: https://registry.npmjs.org/frac/-/frac-1.1.2.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
fresh@0.5.2:
|
fresh@0.5.2:
|
||||||
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==, tarball: https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz}
|
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==, tarball: https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
@ -2261,6 +2510,9 @@ packages:
|
||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, tarball: https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, tarball: https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz}
|
||||||
|
|
||||||
|
grapheme-splitter@1.0.4:
|
||||||
|
resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==, tarball: https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz}
|
||||||
|
|
||||||
has-ansi@4.0.1:
|
has-ansi@4.0.1:
|
||||||
resolution: {integrity: sha512-Qr4RtTm30xvEdqUXbSBVWDu+PrTokJOwe/FU+VdfJPk+MXAPoeOzKpRyrDTnZIJwAkQ4oBLTU53nu0HrkF/Z2A==, tarball: https://registry.npmjs.org/has-ansi/-/has-ansi-4.0.1.tgz}
|
resolution: {integrity: sha512-Qr4RtTm30xvEdqUXbSBVWDu+PrTokJOwe/FU+VdfJPk+MXAPoeOzKpRyrDTnZIJwAkQ4oBLTU53nu0HrkF/Z2A==, tarball: https://registry.npmjs.org/has-ansi/-/has-ansi-4.0.1.tgz}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
@ -2306,6 +2558,10 @@ packages:
|
||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
http-proxy-agent@5.0.0:
|
||||||
|
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==, tarball: https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
http-signature@1.4.0:
|
http-signature@1.4.0:
|
||||||
resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==, tarball: https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz}
|
resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==, tarball: https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
|
|
@ -2558,6 +2814,9 @@ packages:
|
||||||
knuth-shuffle-seeded@1.0.6:
|
knuth-shuffle-seeded@1.0.6:
|
||||||
resolution: {integrity: sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==, tarball: https://registry.npmjs.org/knuth-shuffle-seeded/-/knuth-shuffle-seeded-1.0.6.tgz}
|
resolution: {integrity: sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==, tarball: https://registry.npmjs.org/knuth-shuffle-seeded/-/knuth-shuffle-seeded-1.0.6.tgz}
|
||||||
|
|
||||||
|
kuromoji@0.1.2:
|
||||||
|
resolution: {integrity: sha512-V0dUf+C2LpcPEXhoHLMAop/bOht16Dyr+mDiIE39yX3vqau7p80De/koFqpiTcL1zzdZlc3xuHZ8u5gjYRfFaQ==, tarball: https://registry.npmjs.org/kuromoji/-/kuromoji-0.1.2.tgz}
|
||||||
|
|
||||||
lazy-ass@1.6.0:
|
lazy-ass@1.6.0:
|
||||||
resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==, tarball: https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz}
|
resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==, tarball: https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz}
|
||||||
engines: {node: '> 0.8'}
|
engines: {node: '> 0.8'}
|
||||||
|
|
@ -2822,6 +3081,9 @@ packages:
|
||||||
node-html-parser@5.3.3:
|
node-html-parser@5.3.3:
|
||||||
resolution: {integrity: sha512-ncg1033CaX9UexbyA7e1N0aAoAYRDiV8jkTvzEnfd1GDvzFdrsXLzR4p4ik8mwLgnaKP/jyUFWDy9q3jvRT2Jw==, tarball: https://registry.npmjs.org/node-html-parser/-/node-html-parser-5.3.3.tgz}
|
resolution: {integrity: sha512-ncg1033CaX9UexbyA7e1N0aAoAYRDiV8jkTvzEnfd1GDvzFdrsXLzR4p4ik8mwLgnaKP/jyUFWDy9q3jvRT2Jw==, tarball: https://registry.npmjs.org/node-html-parser/-/node-html-parser-5.3.3.tgz}
|
||||||
|
|
||||||
|
node-nlp@4.27.0:
|
||||||
|
resolution: {integrity: sha512-LnkhOUPXX0CMFbSzJ1gHI+7Yb3ULLip5gRsqedXb6pryjcRCbNzPgHXcH/6G9B1vSbDfO+y3X2B4QZpfP12OyQ==, tarball: https://registry.npmjs.org/node-nlp/-/node-nlp-4.27.0.tgz}
|
||||||
|
|
||||||
node-releases@2.0.53:
|
node-releases@2.0.53:
|
||||||
resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==, tarball: https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz}
|
resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==, tarball: https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -3371,6 +3633,10 @@ packages:
|
||||||
split@1.0.1:
|
split@1.0.1:
|
||||||
resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==, tarball: https://registry.npmjs.org/split/-/split-1.0.1.tgz}
|
resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==, tarball: https://registry.npmjs.org/split/-/split-1.0.1.tgz}
|
||||||
|
|
||||||
|
ssf@0.11.2:
|
||||||
|
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==, tarball: https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
sshpk@1.18.0:
|
sshpk@1.18.0:
|
||||||
resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==, tarball: https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz}
|
resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==, tarball: https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -3711,6 +3977,14 @@ packages:
|
||||||
wide-align@1.1.5:
|
wide-align@1.1.5:
|
||||||
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==, tarball: https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz}
|
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==, tarball: https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz}
|
||||||
|
|
||||||
|
wmf@1.0.2:
|
||||||
|
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==, tarball: https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
|
word@0.3.0:
|
||||||
|
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==, tarball: https://registry.npmjs.org/word/-/word-0.3.0.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
workerpool@6.5.1:
|
workerpool@6.5.1:
|
||||||
resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==, tarball: https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz}
|
resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==, tarball: https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz}
|
||||||
|
|
||||||
|
|
@ -3732,6 +4006,11 @@ packages:
|
||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, tarball: https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, tarball: https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz}
|
||||||
|
|
||||||
|
xlsx@0.18.5:
|
||||||
|
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==, tarball: https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
xmlbuilder@15.1.1:
|
xmlbuilder@15.1.1:
|
||||||
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==, tarball: https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz}
|
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==, tarball: https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz}
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
|
|
@ -3785,6 +4064,9 @@ packages:
|
||||||
yup@1.6.1:
|
yup@1.6.1:
|
||||||
resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==, tarball: https://registry.npmjs.org/yup/-/yup-1.6.1.tgz}
|
resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==, tarball: https://registry.npmjs.org/yup/-/yup-1.6.1.tgz}
|
||||||
|
|
||||||
|
zlibjs@0.3.1:
|
||||||
|
resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==, tarball: https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz}
|
||||||
|
|
||||||
zod@3.25.76:
|
zod@3.25.76:
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, tarball: https://registry.npmjs.org/zod/-/zod-3.25.76.tgz}
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, tarball: https://registry.npmjs.org/zod/-/zod-3.25.76.tgz}
|
||||||
|
|
||||||
|
|
@ -4393,9 +4675,344 @@ snapshots:
|
||||||
- encoding
|
- encoding
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-choice@1.3.1':
|
||||||
|
dependencies:
|
||||||
|
'@microsoft/recognizers-text': 1.3.1
|
||||||
|
grapheme-splitter: 1.0.4
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-data-types-timex-expression@1.3.1': {}
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-date-time@1.3.2':
|
||||||
|
dependencies:
|
||||||
|
'@microsoft/recognizers-text': 1.3.1
|
||||||
|
'@microsoft/recognizers-text-number': 1.3.1
|
||||||
|
'@microsoft/recognizers-text-number-with-unit': 1.3.1
|
||||||
|
lodash: 4.18.1
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-number-with-unit@1.3.1':
|
||||||
|
dependencies:
|
||||||
|
'@microsoft/recognizers-text': 1.3.1
|
||||||
|
'@microsoft/recognizers-text-number': 1.3.1
|
||||||
|
lodash: 4.18.1
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-number@1.3.1':
|
||||||
|
dependencies:
|
||||||
|
'@microsoft/recognizers-text': 1.3.1
|
||||||
|
bignumber.js: 7.2.1
|
||||||
|
lodash: 4.18.1
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-sequence@1.3.1':
|
||||||
|
dependencies:
|
||||||
|
'@microsoft/recognizers-text': 1.3.1
|
||||||
|
grapheme-splitter: 1.0.4
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-suite@1.3.0':
|
||||||
|
dependencies:
|
||||||
|
'@microsoft/recognizers-text': 1.3.1
|
||||||
|
'@microsoft/recognizers-text-choice': 1.3.1
|
||||||
|
'@microsoft/recognizers-text-data-types-timex-expression': 1.3.1
|
||||||
|
'@microsoft/recognizers-text-date-time': 1.3.2
|
||||||
|
'@microsoft/recognizers-text-number': 1.3.1
|
||||||
|
'@microsoft/recognizers-text-number-with-unit': 1.3.1
|
||||||
|
'@microsoft/recognizers-text-sequence': 1.3.1
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text@1.3.1': {}
|
||||||
|
|
||||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@nlpjs/builtin-duckling@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/builtin-microsoft@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@microsoft/recognizers-text-suite': 1.3.0
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/core-loader@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
'@nlpjs/request': 4.25.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@nlpjs/core@4.26.1': {}
|
||||||
|
|
||||||
|
'@nlpjs/emoji@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/evaluator@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
escodegen: 2.1.0
|
||||||
|
esprima: 4.0.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-all@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
'@nlpjs/lang-ar': 4.26.1
|
||||||
|
'@nlpjs/lang-bn': 4.26.1
|
||||||
|
'@nlpjs/lang-ca': 4.26.1
|
||||||
|
'@nlpjs/lang-cs': 4.26.1
|
||||||
|
'@nlpjs/lang-da': 4.26.1
|
||||||
|
'@nlpjs/lang-de': 4.26.1
|
||||||
|
'@nlpjs/lang-el': 4.26.1
|
||||||
|
'@nlpjs/lang-en': 4.26.1
|
||||||
|
'@nlpjs/lang-es': 4.26.1
|
||||||
|
'@nlpjs/lang-eu': 4.26.1
|
||||||
|
'@nlpjs/lang-fa': 4.26.1
|
||||||
|
'@nlpjs/lang-fi': 4.26.1
|
||||||
|
'@nlpjs/lang-fr': 4.26.1
|
||||||
|
'@nlpjs/lang-ga': 4.26.1
|
||||||
|
'@nlpjs/lang-gl': 4.26.1
|
||||||
|
'@nlpjs/lang-hi': 4.26.1
|
||||||
|
'@nlpjs/lang-hu': 4.26.1
|
||||||
|
'@nlpjs/lang-hy': 4.26.1
|
||||||
|
'@nlpjs/lang-id': 4.26.1
|
||||||
|
'@nlpjs/lang-it': 4.26.1
|
||||||
|
'@nlpjs/lang-ja': 4.26.1
|
||||||
|
'@nlpjs/lang-ko': 4.26.1
|
||||||
|
'@nlpjs/lang-lt': 4.26.1
|
||||||
|
'@nlpjs/lang-ms': 4.26.1
|
||||||
|
'@nlpjs/lang-ne': 4.26.1
|
||||||
|
'@nlpjs/lang-nl': 4.26.1
|
||||||
|
'@nlpjs/lang-no': 4.26.1
|
||||||
|
'@nlpjs/lang-pl': 4.26.1
|
||||||
|
'@nlpjs/lang-pt': 4.26.1
|
||||||
|
'@nlpjs/lang-ro': 4.26.1
|
||||||
|
'@nlpjs/lang-ru': 4.26.1
|
||||||
|
'@nlpjs/lang-sl': 4.26.1
|
||||||
|
'@nlpjs/lang-sr': 4.26.1
|
||||||
|
'@nlpjs/lang-sv': 4.26.1
|
||||||
|
'@nlpjs/lang-ta': 4.26.1
|
||||||
|
'@nlpjs/lang-th': 4.26.1
|
||||||
|
'@nlpjs/lang-tl': 4.26.1
|
||||||
|
'@nlpjs/lang-tr': 4.26.1
|
||||||
|
'@nlpjs/lang-uk': 4.26.1
|
||||||
|
'@nlpjs/lang-zh': 4.26.1
|
||||||
|
'@nlpjs/language': 4.25.0
|
||||||
|
|
||||||
|
'@nlpjs/lang-ar@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-bn@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-ca@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-cs@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-da@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-de@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-el@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-en-min@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-en@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
'@nlpjs/lang-en-min': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-es@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-eu@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-fa@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-fi@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-fr@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-ga@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-gl@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-hi@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-hu@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-hy@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-id@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-it@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-ja@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
kuromoji: 0.1.2
|
||||||
|
|
||||||
|
'@nlpjs/lang-ko@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-lt@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-ms@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
'@nlpjs/lang-id': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-ne@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-nl@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-no@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-pl@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-pt@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-ro@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-ru@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-sl@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-sr@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-sv@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-ta@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-th@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-tl@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-tr@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-uk@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-zh@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/language-min@4.25.0': {}
|
||||||
|
|
||||||
|
'@nlpjs/language@4.25.0': {}
|
||||||
|
|
||||||
|
'@nlpjs/ner@4.27.0':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
'@nlpjs/language-min': 4.25.0
|
||||||
|
'@nlpjs/similarity': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/neural@4.25.0': {}
|
||||||
|
|
||||||
|
'@nlpjs/nlg@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/nlp@4.27.0':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
'@nlpjs/ner': 4.27.0
|
||||||
|
'@nlpjs/nlg': 4.26.1
|
||||||
|
'@nlpjs/nlu': 4.27.0
|
||||||
|
'@nlpjs/sentiment': 4.26.1
|
||||||
|
'@nlpjs/slot': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/nlu@4.27.0':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
'@nlpjs/language-min': 4.25.0
|
||||||
|
'@nlpjs/neural': 4.25.0
|
||||||
|
'@nlpjs/similarity': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/request@4.25.0':
|
||||||
|
dependencies:
|
||||||
|
http-proxy-agent: 5.0.0
|
||||||
|
https-proxy-agent: 5.0.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@nlpjs/sentiment@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
'@nlpjs/language-min': 4.25.0
|
||||||
|
'@nlpjs/neural': 4.25.0
|
||||||
|
|
||||||
|
'@nlpjs/similarity@4.26.1': {}
|
||||||
|
|
||||||
|
'@nlpjs/slot@4.26.1': {}
|
||||||
|
|
||||||
|
'@nlpjs/xtables@4.25.0':
|
||||||
|
dependencies:
|
||||||
|
xlsx: 0.18.5
|
||||||
|
|
||||||
'@noble/hashes@1.8.0': {}
|
'@noble/hashes@1.8.0': {}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
|
|
@ -4582,6 +5199,8 @@ snapshots:
|
||||||
|
|
||||||
'@teppeis/multimaps@3.0.0': {}
|
'@teppeis/multimaps@3.0.0': {}
|
||||||
|
|
||||||
|
'@tootallnate/once@2.0.1': {}
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.29.8
|
'@babel/parser': 7.29.8
|
||||||
|
|
@ -4804,6 +5423,8 @@ snapshots:
|
||||||
|
|
||||||
acorn@8.18.0: {}
|
acorn@8.18.0: {}
|
||||||
|
|
||||||
|
adler-32@1.3.1: {}
|
||||||
|
|
||||||
agent-base@6.0.2:
|
agent-base@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
|
|
@ -4893,6 +5514,10 @@ snapshots:
|
||||||
|
|
||||||
astral-regex@2.0.0: {}
|
astral-regex@2.0.0: {}
|
||||||
|
|
||||||
|
async@2.6.4:
|
||||||
|
dependencies:
|
||||||
|
lodash: 4.18.1
|
||||||
|
|
||||||
async@3.2.6: {}
|
async@3.2.6: {}
|
||||||
|
|
||||||
asynckit@0.4.0: {}
|
asynckit@0.4.0: {}
|
||||||
|
|
@ -4929,6 +5554,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
tweetnacl: 0.14.5
|
tweetnacl: 0.14.5
|
||||||
|
|
||||||
|
bignumber.js@7.2.1: {}
|
||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
blob-util@2.0.2: {}
|
blob-util@2.0.2: {}
|
||||||
|
|
@ -5027,6 +5654,11 @@ snapshots:
|
||||||
|
|
||||||
caseless@0.12.0: {}
|
caseless@0.12.0: {}
|
||||||
|
|
||||||
|
cfb@1.2.2:
|
||||||
|
dependencies:
|
||||||
|
adler-32: 1.3.1
|
||||||
|
crc-32: 1.2.2
|
||||||
|
|
||||||
chai@5.3.3:
|
chai@5.3.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
assertion-error: 2.0.1
|
assertion-error: 2.0.1
|
||||||
|
|
@ -5106,6 +5738,8 @@ snapshots:
|
||||||
clone@1.0.4:
|
clone@1.0.4:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
codepage@1.15.0: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
|
|
@ -5187,6 +5821,8 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
crc-32@1.2.2: {}
|
||||||
|
|
||||||
cross-env@10.1.0:
|
cross-env@10.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@epic-web/invariant': 1.0.0
|
'@epic-web/invariant': 1.0.0
|
||||||
|
|
@ -5431,6 +6067,8 @@ snapshots:
|
||||||
|
|
||||||
dotenv@16.6.1: {}
|
dotenv@16.6.1: {}
|
||||||
|
|
||||||
|
doublearray@0.0.2: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
|
@ -5823,6 +6461,8 @@ snapshots:
|
||||||
|
|
||||||
forwarded@0.2.0: {}
|
forwarded@0.2.0: {}
|
||||||
|
|
||||||
|
frac@1.1.2: {}
|
||||||
|
|
||||||
fresh@0.5.2: {}
|
fresh@0.5.2: {}
|
||||||
|
|
||||||
from@0.1.7: {}
|
from@0.1.7: {}
|
||||||
|
|
@ -5944,6 +6584,8 @@ snapshots:
|
||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
|
grapheme-splitter@1.0.4: {}
|
||||||
|
|
||||||
has-ansi@4.0.1:
|
has-ansi@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-regex: 4.1.1
|
ansi-regex: 4.1.1
|
||||||
|
|
@ -5984,6 +6626,14 @@ snapshots:
|
||||||
statuses: 2.0.2
|
statuses: 2.0.2
|
||||||
toidentifier: 1.0.1
|
toidentifier: 1.0.1
|
||||||
|
|
||||||
|
http-proxy-agent@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@tootallnate/once': 2.0.1
|
||||||
|
agent-base: 6.0.2
|
||||||
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
http-signature@1.4.0:
|
http-signature@1.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
assert-plus: 1.0.0
|
assert-plus: 1.0.0
|
||||||
|
|
@ -6226,6 +6876,12 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
seed-random: 2.2.0
|
seed-random: 2.2.0
|
||||||
|
|
||||||
|
kuromoji@0.1.2:
|
||||||
|
dependencies:
|
||||||
|
async: 2.6.4
|
||||||
|
doublearray: 0.0.2
|
||||||
|
zlibjs: 0.3.1
|
||||||
|
|
||||||
lazy-ass@1.6.0: {}
|
lazy-ass@1.6.0: {}
|
||||||
|
|
||||||
lazy-ass@2.0.3: {}
|
lazy-ass@2.0.3: {}
|
||||||
|
|
@ -6477,6 +7133,26 @@ snapshots:
|
||||||
css-select: 4.3.0
|
css-select: 4.3.0
|
||||||
he: 1.2.0
|
he: 1.2.0
|
||||||
|
|
||||||
|
node-nlp@4.27.0:
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/builtin-duckling': 4.26.1
|
||||||
|
'@nlpjs/builtin-microsoft': 4.26.1
|
||||||
|
'@nlpjs/core-loader': 4.26.1
|
||||||
|
'@nlpjs/emoji': 4.26.1
|
||||||
|
'@nlpjs/evaluator': 4.26.1
|
||||||
|
'@nlpjs/lang-all': 4.26.1
|
||||||
|
'@nlpjs/language': 4.25.0
|
||||||
|
'@nlpjs/neural': 4.25.0
|
||||||
|
'@nlpjs/nlg': 4.26.1
|
||||||
|
'@nlpjs/nlp': 4.27.0
|
||||||
|
'@nlpjs/nlu': 4.27.0
|
||||||
|
'@nlpjs/request': 4.25.0
|
||||||
|
'@nlpjs/sentiment': 4.26.1
|
||||||
|
'@nlpjs/similarity': 4.26.1
|
||||||
|
'@nlpjs/xtables': 4.25.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
node-releases@2.0.53: {}
|
node-releases@2.0.53: {}
|
||||||
|
|
||||||
node-source-walk@7.0.2:
|
node-source-walk@7.0.2:
|
||||||
|
|
@ -7075,6 +7751,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
through: 2.3.8
|
through: 2.3.8
|
||||||
|
|
||||||
|
ssf@0.11.2:
|
||||||
|
dependencies:
|
||||||
|
frac: 1.1.2
|
||||||
|
|
||||||
sshpk@1.18.0:
|
sshpk@1.18.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
asn1: 0.2.6
|
asn1: 0.2.6
|
||||||
|
|
@ -7399,6 +8079,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
|
|
||||||
|
wmf@1.0.2: {}
|
||||||
|
|
||||||
|
word@0.3.0: {}
|
||||||
|
|
||||||
workerpool@6.5.1: {}
|
workerpool@6.5.1: {}
|
||||||
|
|
||||||
workerpool@9.3.4: {}
|
workerpool@9.3.4: {}
|
||||||
|
|
@ -7423,6 +8107,16 @@ snapshots:
|
||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
xlsx@0.18.5:
|
||||||
|
dependencies:
|
||||||
|
adler-32: 1.3.1
|
||||||
|
cfb: 1.2.2
|
||||||
|
codepage: 1.15.0
|
||||||
|
crc-32: 1.2.2
|
||||||
|
ssf: 0.11.2
|
||||||
|
wmf: 1.0.2
|
||||||
|
word: 0.3.0
|
||||||
|
|
||||||
xmlbuilder@15.1.1: {}
|
xmlbuilder@15.1.1: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
@ -7480,4 +8174,6 @@ snapshots:
|
||||||
toposort: 2.0.2
|
toposort: 2.0.2
|
||||||
type-fest: 2.19.0
|
type-fest: 2.19.0
|
||||||
|
|
||||||
|
zlibjs@0.3.1: {}
|
||||||
|
|
||||||
zod@3.25.76: {}
|
zod@3.25.76: {}
|
||||||
|
|
|
||||||
|
|
@ -424,31 +424,75 @@ invisible tant que le profil n'a pas rejoint/créé de foyer.
|
||||||
|
|
||||||
### Détection des techniques — `tech-step-matcher.ts`
|
### Détection des techniques — `tech-step-matcher.ts`
|
||||||
|
|
||||||
`normalizeText` : décomposition NFD + suppression des diacritiques combinants
|
Historiquement une table `TechStepMapping` de regex par technique/locale
|
||||||
+ minuscule (ex. "Déglacer" → "deglacer"), appliquée à la fois au texte de
|
(`weight` pour départager les chevauchements) — remplacée par un pipeline
|
||||||
l'étape et aux expressions des mappings — permet d'écrire les expressions
|
`node-nlp` (`TechStepClassifierService`) une fois constaté que les regex ne
|
||||||
françaises accentuées naturellement dans `reference-seed-data.ts` tout en
|
généralisaient jamais au-delà de leur propre vocabulaire : une étape décrivant
|
||||||
matchant indépendamment des accents/de la casse.
|
la fonte du beurre comme "jusqu'à ce que le beurre ait disparu dans la poêle"
|
||||||
|
ne contient aucun verbe sur lequel une regex pourrait s'ancrer, alors que le
|
||||||
|
sens est sans ambiguïté. `TechStepMapping` a été supprimée (migration
|
||||||
|
`20260821130000_drop_tech_step_mapping`) — plus aucune table n'est
|
||||||
|
interrogée/éditée à l'exécution, les données de matching vivent en code
|
||||||
|
(`tech-step-training-data.ts`).
|
||||||
|
|
||||||
`matchTechStepSpans(description, mappings)` — algorithme en 4 étapes :
|
`normalizeText` (décomposition NFD + suppression des diacritiques + minuscule)
|
||||||
1. teste chaque `expression` (source de regex) contre la description
|
reste utilisée par `ingredient-matcher.ts`, mais n'intervient plus dans la
|
||||||
normalisée ;
|
détection des techniques elle-même — node-nlp gère sa propre normalisation
|
||||||
2. pour une même technique, ne garde que le meilleur candidat (`weight` le
|
par langue.
|
||||||
plus élevé, égalité départagée par la position la plus précoce) ;
|
|
||||||
3. entre techniques **différentes** dont les spans se chevauchent encore
|
|
||||||
(ex. `cook` générique matchant dans "cuire au four", plus spécifique
|
|
||||||
`bake`), résolution gloutonne par poids décroissant — un candidat n'est
|
|
||||||
accepté que s'il ne chevauche aucun déjà accepté (ce qui permet à des
|
|
||||||
techniques non-chevauchantes de coexister dans une même phrase, tout en
|
|
||||||
éliminant un match redondant) ;
|
|
||||||
4. tri final par position de départ.
|
|
||||||
|
|
||||||
`start`/`end` renvoyés sont des offsets dans le texte **normalisé**, réutilisés
|
**Pipeline en 3 étapes** (`TechStepClassifierService.matchTechStepSpans`) :
|
||||||
tels quels contre le texte **original** pour le surlignage — repose sur
|
1. **NER** (entités enum node-nlp, `synonyms` de `TECH_STEP_TRAINING_DATA`)
|
||||||
l'hypothèse documentée (et acceptée) que la décomposition NFD n'augmente
|
trouve chaque mention *candidate* d'une technique dans la description
|
||||||
jamais le nombre de caractères d'un texte français en pratique.
|
entière, avec sa position exacte — équivalent mécanique des anciennes
|
||||||
`loadTechStepMappingRules(locale)` est la seule pièce qui touche la base — à
|
regex, en listes de synonymes plutôt qu'en patterns écrits à la main.
|
||||||
appeler une fois par requête, pas par étape.
|
`ner.threshold: 1` (exact après normalisation, pas de tolérance floue
|
||||||
|
Levenshtein) — le défaut à 0.8 faisait matcher "faire" (verbe auxiliaire
|
||||||
|
omniprésent en français) contre le synonyme "frire" de `fry` par pure
|
||||||
|
proximité de chaîne, un faux positif détecté en calibrant contre le
|
||||||
|
corpus réel.
|
||||||
|
2. La description est découpée en clauses autour de ces candidats
|
||||||
|
(`splitIntoClauses`, pure/testable sans modèle) — une étape nommant deux
|
||||||
|
techniques a besoin que chacune soit jugée sur son propre contexte, pas
|
||||||
|
la phrase entière classée d'un bloc.
|
||||||
|
3. **Classification d'intention NLP** (le même `NlpManager`, entraîné sur les
|
||||||
|
`utterances` de `TECH_STEP_TRAINING_DATA`) classe chaque clause
|
||||||
|
individuellement — c'est ce qui apporte la compréhension du **sens** :
|
||||||
|
le corpus d'entraînement mélange volontairement des tournures ancrées sur
|
||||||
|
le mot-clé et des paraphrases qui ne l'emploient jamais (ex. "jusqu'à ce
|
||||||
|
que le beurre ait disparu" pour `melt`), donc le verdict final d'une
|
||||||
|
clause vient de ce que le modèle reconnaît comme *signifiant* la
|
||||||
|
technique, pas du mot littéral qui a déclenché son découpage. En dessous
|
||||||
|
de `CONFIDENCE_THRESHOLD` (0.65 — ajusté empiriquement contre le corpus
|
||||||
|
réel, voir `test/tech-step-matcher.test.ts`), retombe sur la technique
|
||||||
|
impliquée par l'ancre NER de la clause plutôt que d'abandonner un match
|
||||||
|
clairement ancré sur un mot-clé juste parce qu'un petit modèle n'est pas
|
||||||
|
assez confiant.
|
||||||
|
|
||||||
|
Entraînement (`_train`) et résolution `TechStep.key -> id` sont mémoïsés une
|
||||||
|
seule fois sur le singleton partagé `techStepClassifier` (jamais par requête).
|
||||||
|
Le tout premier appel réel à `NlpManager.process()` déclenche aussi le
|
||||||
|
chargement paresseux des ressources par langue de node-nlp (plusieurs
|
||||||
|
secondes, mesuré) — `server.ts` appelle `techStepClassifier.warmUp()` avant
|
||||||
|
d'accepter du trafic pour que ce ne soit jamais la première vraie requête qui
|
||||||
|
attend.
|
||||||
|
|
||||||
|
**Deux pièges rencontrés en construisant ce pipeline**, tous deux corrigés
|
||||||
|
dans le code (pas juste contournés) :
|
||||||
|
- `db/prisma.ts` construisait `new PrismaClient()` sans jamais importer
|
||||||
|
`config/env.ts` — dans le run de test complet, un *autre* fichier
|
||||||
|
chargeait toujours `config/env.ts` (donc `.env.test`) en premier par pur
|
||||||
|
hasard d'ordre de résolution des modules ; lancer un seul fichier de test
|
||||||
|
isolément pouvait faire gagner la course au chargement `.env` interne de
|
||||||
|
Prisma (le chemin `.env` de dev, baké dans le client généré) — silencieux
|
||||||
|
tant que `resetDatabase()` ne throw pas (heureusement son garde-fou le
|
||||||
|
fait). Fixé en import `config/env.js` pour effet de bord tout en haut de
|
||||||
|
`prisma.ts`, avant `new PrismaClient()`.
|
||||||
|
- `NlpManager` a `autoSave`/`autoLoad: true` par défaut — persiste le
|
||||||
|
modèle entraîné dans un fichier `model.nlp` (cwd du process) et le
|
||||||
|
recharge *au lieu de* ré-entraîner au prochain démarrage s'il existe déjà.
|
||||||
|
Un modèle obsolète sur disque masquerait silencieusement toute mise à
|
||||||
|
jour de `TECH_STEP_TRAINING_DATA`/`CONFIDENCE_THRESHOLD`. Les deux sont
|
||||||
|
explicitement à `false` dans le constructeur de `TechStepClassifierService`.
|
||||||
|
|
||||||
### Résolution ingrédients/unités — `ingredient-matcher.ts`
|
### Résolution ingrédients/unités — `ingredient-matcher.ts`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ d'ingrédients/unités normalisé, techniques détectées, visibilité) :
|
||||||
- **Utilisateurs & foyer** — `UserProfile`, `House`, `Diet`, `Category`, `Allergy`,
|
- **Utilisateurs & foyer** — `UserProfile`, `House`, `Diet`, `Category`, `Allergy`,
|
||||||
`UserPreference` (thème)
|
`UserPreference` (thème)
|
||||||
- **Planification** — `Planning`, `PlanningItem`
|
- **Planification** — `Planning`, `PlanningItem`
|
||||||
- **Recettes** — `Recipe`, `RecipeIngredient`, `Step`, `TechStep`, `TechStepMapping`,
|
- **Recettes** — `Recipe`, `RecipeIngredient`, `Step`, `TechStep`,
|
||||||
`StepTechStep`, `RecipeDiet`, `RecipeFavorite`
|
`StepTechStep`, `RecipeDiet`, `RecipeFavorite`
|
||||||
- **Sources externes** — `Source`, `HouseSource`
|
- **Sources externes** — `Source`, `HouseSource`
|
||||||
- **Catalogue ingrédients/unités** — `Ingredient`, `Unit`, `IngredientDiet`,
|
- **Catalogue ingrédients/unités** — `Ingredient`, `Unit`, `IngredientDiet`,
|
||||||
|
|
@ -351,11 +351,14 @@ fiable.
|
||||||
| `order` | Position dans la recette |
|
| `order` | Position dans la recette |
|
||||||
|
|
||||||
`tech_step` (`TechStep`, `key` unique, ex. `"simmer"`) est le catalogue des
|
`tech_step` (`TechStep`, `key` unique, ex. `"simmer"`) est le catalogue des
|
||||||
techniques (mijoter, préchauffer…). `tech_step_mapping` (`TechStepMapping`)
|
techniques (mijoter, préchauffer…) — juste un id/clé stable référencé par
|
||||||
porte les règles de détection : `expression` (regex testée contre la
|
`step_tech_step`. Les données de détection elles-mêmes (synonymes + phrases
|
||||||
description), `weight` (départage en cas de règles concurrentes), `locale`
|
d'exemple par langue, entraînant un classifieur `node-nlp`) vivent en code
|
||||||
(une même technique peut avoir un jeu de règles par langue — voir
|
(`tech-step-training-data.ts`), pas dans une table — l'ancienne
|
||||||
[backend-architecture.md](./backend-architecture.md#détection-des-techniques--tech-step-matcherts)).
|
`tech_step_mapping` (`TechStepMapping`, une regex par technique/locale) a
|
||||||
|
été supprimée une fois constaté que les regex ne généralisaient jamais
|
||||||
|
au-delà de leur propre vocabulaire — voir
|
||||||
|
[backend-architecture.md](./backend-architecture.md#détection-des-techniques--tech-step-matcherts).
|
||||||
|
|
||||||
`step_tech_step` (`StepTechStep`) est la **séquence ordonnée** des techniques
|
`step_tech_step` (`StepTechStep`) est la **séquence ordonnée** des techniques
|
||||||
détectées pour une étape — une instruction peut en impliquer plusieurs (ex.
|
détectées pour une étape — une instruction peut en impliquer plusieurs (ex.
|
||||||
|
|
@ -386,7 +389,6 @@ dans `Step.description`, utilisé pour le surlignage côté web
|
||||||
| `recipe` | `authorId` | `user_profiles` |
|
| `recipe` | `authorId` | `user_profiles` |
|
||||||
| `recipe` | `authorHouseId` | `house` |
|
| `recipe` | `authorHouseId` | `house` |
|
||||||
| `step` | `recipeId` | `recipe` |
|
| `step` | `recipeId` | `recipe` |
|
||||||
| `tech_step_mapping` | `techStepId` | `tech_step` |
|
|
||||||
|
|
||||||
### Many-to-many (tables de jointure explicites, avec ou sans champ additionnel)
|
### Many-to-many (tables de jointure explicites, avec ou sans champ additionnel)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue