Compare commits

...

15 commits

Author SHA1 Message Date
79ed5afdba fix(experiments): corrige les échecs de parsing JSON intermittents du moteur Ollama
Diagnostiqué et reproduit : sur un petit modèle (qwen2.5:0.5b) face à une
phrase longue (fr-concat-volumetrie, fr-recette-complete), le modèle part
en boucle de répétition dans le tableau `actions` et n'atteint jamais
l'accolade fermante avant la limite de tokens (response.done_reason ===
"length", jusqu'à ~130 000 caractères observés). La grammaire imposée par
`format` ne borne que la syntaxe token par token, pas la longueur du
tableau.

- options.repeat_penalty (1.3) décourage la boucle — réduit le dérapage
  d'un facteur ~18 sur le pire cas reproduit, sans l'éliminer à coup sûr.
- options.num_predict (2048) borne le dégât si ça dérape quand même.
- analyzeStep() réessaie jusqu'à 3 fois sur un parse invalide, avec une
  température légèrement relevée (0.3) à partir de la 2e tentative — à
  température 0 stricte, retenter à l'identique peut reproduire l'échec.

Vérifié : 3/3 runs réussissent sur le pire cas reproduit après correctif,
contre un échec systématique avant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 00:12:33 +02:00
f5f30923d0 feat(experiments): ajoute un 4e moteur — même LLM via Ollama
Nouveau src/ollama-tech-step-poc.ts : même tâche/SYSTEM_PROMPT (exporté
depuis llm-tech-step-poc.ts et réutilisé tel quel) que le moteur
node-llama-cpp, mais via Ollama — une implémentation architecturalement
différente plutôt qu'une redite :

- Ollama tourne comme serveur HTTP local séparé (ollama serve), pas comme
  binding natif dans ce process — le paquet npm ollama n'a aucune
  dépendance native (rien à compiler à l'install, contrairement à
  node-llama-cpp).
- Modèle géré par Ollama lui-même (ollama.pull(), cache dans
  ~/.ollama/models), pas par ce projet — progression de pull journalisée
  palier par palier plutôt que silencieuse.
- Schéma JSON imposé via `format` (JSON Schema standard, `type:
  ["string","null"]` pour un champ nullable) — plus simple que le détour
  `oneOf` qu'exige la grammaire GBNF de node-llama-cpp.
- initialize() échoue avec un message explicite si le serveur Ollama n'est
  pas joignable, plutôt que l'erreur fetch brute.
- Caveat documenté en tête de fichier et rappelé avant le récapitulatif :
  la colonne RSS du harness ne mesure rien d'utile ici, l'inférence tourne
  dans le process ollama serve, pas dans ce script.

OllamaStepAnalyzer.dispose() décharge le modèle du serveur (keep_alive: 0,
best effort). Env vars OLLAMA_TECH_STEP_MODEL/OLLAMA_TECH_STEP_HOST,
scripts pnpm bench:ollama.

Vérifié en conditions réelles (Ollama tournait déjà dans l'environnement) :
pull + inférence structurée + parsing JSON fonctionnels, latence nettement
inférieure à node-llama-cpp sur les mêmes phrases (748-1260 ms vs 3-13 s),
delta RSS confirmé proche de zéro/bruit comme attendu.

README mis à jour (4 moteurs, section Ollama avec tableau comparatif
architectural, limites).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 23:14:03 +02:00
b8e599106f feat(experiments): ajoute une entrée de volumétrie par locale basée sur TEST_RECIPE
TEST_SENTENCES gagne 2 entrées supplémentaires (fr-recette-complete,
en-recette-complete) qui concatènent les 7 étapes de TEST_RECIPE ("Tarte
aux pommes rustique") en un seul step par locale — même principe que
fr-concat-volumetrie/en-concat-volumetrie, mais sur du texte de recette
réel plutôt qu'une concaténation de phrases-pièges synthétiques.

TEST_RECIPE (déjà ajoutée localement) déplacée avant TEST_SENTENCES
(nécessaire pour être référencée dans sa construction) et reformatée à la
convention du fichier (clés non citées, virgules finales) ; contenu
inchangé. Retire le stub dummyRecipeFr/dummyRecipeEn de
benchmark-harness.ts : le harness générique n'a besoin d'aucun
cas particulier, TEST_SENTENCES suffit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 22:48:39 +02:00
9f274f9461 feat(experiments): ajoute une entrée de volumétrie par locale (FR/EN)
TEST_SENTENCES gagne 2 entrées dérivées (fr-concat-volumetrie,
en-concat-volumetrie) qui concatènent toutes les phrases de base d'une
même locale en un seul step géant, calculées depuis les phrases existantes
(jamais recopiées à la main) — pour isoler l'effet du seul volume de texte
sur la durée de traitement de chaque moteur, indépendamment de la
complexité déjà couverte par les 7 phrases existantes.

Vérifié via bench:nlp : la latence croît nettement avec le volume
(fr-concat ~2200ms vs 300-1100ms pour les phrases individuelles FR).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 22:15:45 +02:00
c73c62328d refactor(experiments): retire le script apps/api, ajoute NLP frais + pipeline hybride
Étape 1 — retire apps/api/src/scripts/bench-tech-step-classifier.ts
(DB-backed, taxonomie ~26 techniques non comparable terme à terme au LLM).

Étape 2 — reconstruit tout dans experiments/llm-tech-step-poc, entièrement
autonome (aucune dépendance Postgres/apps/api) :

- shared/kitchen-action.ts, shared/test-sentences.ts,
  shared/benchmark-harness.ts : types, 7 phrases de test et harness de
  mesure/affichage désormais partagés par les trois scripts (plus de
  recopie manuelle entre fichiers).
- nlp-tech-step-poc.ts : classifieur node-nlp FRAIS (NER + clauses +
  classification), entraîné directement sur la taxonomie à 7 catégories du
  LLM plutôt que réutiliser TechStepClassifierService — comparaison terme à
  terme, et surtout un score de confiance BRUT jamais masqué (contrairement
  au repli silencieux sur l'ancre NER de la version production), condition
  nécessaire au pipeline hybride. Corpus qui préfère les synonymes mono-mot
  ("revenir") aux phrases figées, pour ne pas se faire piéger par les
  pronoms clitiques français ("faites-les-revenir").
- hybrid-tech-step-poc.ts : NLP toujours en premier (chemin rapide), LLM en
  secours si la confiance NLP passe sous NLP_TRUST_THRESHOLD (0.6, tunable)
  ou qu'aucune action n'est trouvée — récapitulatif avec colonnes "moteur"
  et "confiance NLP" pour observer les bascules.
- llm-tech-step-poc.ts : inchangé fonctionnellement, migré vers les modules
  partagés.
- shared/module-entry.ts (isMainModule) : garde chaque script pour que
  l'import de ses classes (par hybrid-tech-step-poc.ts) ne déclenche pas
  aussi son propre benchmark comme effet de bord.

pnpm bench / bench:nlp / bench:hybrid. README réécrit en conséquence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 21:13:20 +02:00
0582822a78 feat(api): ajoute le pendant node-nlp du benchmark tech-step
Nouveau script apps/api/src/scripts/bench-tech-step-classifier.ts, calqué
sur experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts : mêmes 7
phrases de TEST_SENTENCES (recopiées à l'identique), même structure de
sortie (logs itératifs par répétition, tableau récapitulatif
latence/RSS/nombre de détections), pour que les deux pipelines soient
directement comparables phrase par phrase.

Réutilise techStepClassifier.warmUp() (déjà prévu pour absorber le coût de
l'entraînement + l'init paresseuse de node-nlp) et résout les techStepId en
key lisible pour l'affichage détaillé. Nécessite une base Postgres avec
TechStep seedée (matchTechStepSpans résout ses uid vers de vrais ids).

README du PoC LLM mis à jour : la section "Méthodologie de comparaison"
pointe vers ce script réel plutôt que le snippet REPL manuel qu'elle
suggérait avant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 19:47:15 +02:00
6574d8e4a8 fix(experiments): ajoute un warm-up et des logs itératifs au benchmark LLM
- LocalLlmStepAnalyzer.warmUp() : force le coût caché du tout premier appel
  d'inférence (spin-up threads llama.cpp, cache KV, tokenizer) avant le
  benchmark, plutôt que de laisser la première phrase l'absorber — constaté
  sur des runs réels (Qwen/Llama) où fr-multi-action montait jusqu'à ~28s
  contre ~5s pour ses autres répétitions.
- initialize() et runBenchmark() journalisent maintenant chaque sous-étape
  (résolution du modèle, chargement des poids, contexte, grammaire, puis
  chaque répétition avec son résultat immédiat) au lieu de rester muets
  plusieurs minutes avant le récapitulatif final.
- RECOMMENDED_MODELS / README corrigés suite aux runs réels de l'utilisateur :
  Qwen2.5-1.5B s'est montré systématiquement plus rapide que Llama-3.2-1B
  sur les deux machines testées, contredisant l'hypothèse a priori du README
  ("moins de paramètres = plus rapide") — gardé comme résultat empirique
  plutôt que corrigé silencieusement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 19:32:37 +02:00
f0ffd9644e docs(experiments): documente le --ignore-workspace nécessaire à l'install
pnpm install seul, lancé depuis experiments/llm-tech-step-poc, remonte au
monorepo (pnpm-workspace.yaml) et n'installe rien pour ce dossier hors
workspace — sans erreur visible. --ignore-workspace force pnpm à traiter
le dossier comme un package standalone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 19:10:44 +02:00
0c1743c614 feat(experiments): ajoute 4 phrases de test hyper complexes au PoC LLM
Complète les 3 phrases initiales de TEST_SENTENCES avec 4 cas cherchant
volontairement le point de rupture (au lieu de juste confirmer le cas
courant) : actions simultanées plutôt que séquentielles ("pendant que..."),
action conditionnelle noyée dans des actions fermes, négation explicite
d'action ("sans jamais laisser bouillir"), fin de cuisson par état/test de
résultat plutôt que par durée, et un champ température qui désigne un seuil
de cuisson à cœur plutôt qu'un réglage de feu. README mis à jour (7 phrases,
4 FR + 3 EN).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 19:04:00 +02:00
4c3e00a08a feat(experiments): ajoute un PoC de détection d'actions culinaires par mini LLM local
Fichier TypeScript autonome (hors du workspace pnpm) qui compare le
pipeline node-nlp existant (tech-step-matcher.ts) à un mini LLM instruct
local via node-llama-cpp : sortie JSON strictement contrainte par schéma
(grammaire GBNF, createGrammarForJsonSchema), interfaces RecipeStepAnalysis/
KitchenAction, recommandation de modèle (Qwen2.5-1.5B-Instruct Q4_K_M par
défaut, Llama-3.2-1B-Instruct Q4_K_M en alternative), et un benchmark simple
(performance.now() + delta RSS) sur 3 phrases complexes FR/EN, dont le cas
piège sans verbe littéral déjà documenté dans tech-step-matcher.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 18:58:28 +02:00
f741bbb4f1 fix(web): retire l'affichage visuel du contexte des tech steps
Ne touche que le rendu — le backend continue de calculer et de
persister contextStart/contextEnd (tech-step-matcher.ts, StepTechStep),
et splitDescriptionByTechSteps continue de découper la description
autour du contexte (segments isKeyword: false). StepDescription.tsx
rend désormais ces segments comme du texte brut, comme un segment sans
technique — plus d'encadré/bordure autour de la clause, seul le
mot-clé reste surligné avec sa tooltip.

.step-tech-step-context (CSS) retirée, devenue inutilisée.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 18:42:29 +02:00
c190f3f21c fix(web): rend le contexte des tech steps réellement visible
Le style existant (fond teinté à 6% d'opacité, sans autre indice
visuel) était structurellement correct — vérifié en base, l'API et le
DOM contenaient bien les spans de contexte — mais imperceptible à
l'œil sur ce thème sombre : --color-primary n'est pas assez saturé
pour qu'une teinte de quelques % se distingue du fond de la carte.
Vérifié en créant une recette test dans le navigateur et en zoomant le
texte rendu : littéralement aucune différence visible avant, un
rectangle net après.

Passe à 10% de fond + une bordure basse pleine à 45% d'opacité comme
second indice visuel indépendant, tout en gardant le mot-clé
(soulignement pointillé + fond à 14% + curseur + tooltip) nettement
plus marqué que son contexte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 18:30:49 +02:00
76175bcdbf fix(api): corrige la découpe des clauses et le seuil de confiance du classifieur de tech steps
Trouvé en examinant des vraies recettes déjà en base après le dernier
étoffement du vocabulaire : plusieurs étapes bien réelles se faisaient
classer sur la mauvaise technique, sans lien avec un mot-clé manquant.

- splitIntoClauses coupe désormais sur la limite de phrase (juste après
  un ".", "!" ou "?") la plus proche du milieu de l'écart entre deux
  candidats quand il y en a une, plutôt que sur l'espace brut le plus
  proche du milieu. Une description à deux techniques dans deux phrases
  distinctes ("Préchauffer le four à 180°C. Dans un saladier, mettre le
  beurre... et mélanger.") ne coupait qu'au milieu brut, ce qui pouvait
  trancher en pleine deuxième phrase et envoyer au classifieur une
  clause tronquée ("...(thermostat 6). Dans un saladier, mettre" sans
  complément) — assez éloignée des phrases d'entraînement courtes et
  complètes pour se faire mal classer avec confiance (préchauffer prédit
  "mix", mélanger prédit "melt").
- CONFIDENCE_THRESHOLD passe de 0.65 à 0.75 : du texte anglais passé
  dans le classifieur français (qui doit ne rien trouver, garanti par
  le test d'isolation des locales) scorait 0.69 sur "boil" — du bruit
  de petit corpus, pas un vrai verdict. Les cas réels que ce seuil sert
  à faire confiance scorent 0.91 à 1.0 en pratique ; 0.75 sépare
  proprement le bruit du signal sans rien casser (309 tests toujours
  verts).
- Deux phrases d'entraînement ajoutées à `cook` pour deux clauses
  réelles mal classées (feu doux + remuant, découvert + laisser cuire)
  qui n'avaient pourtant pas de mot-clé manquant.

Ajoute aussi src/scripts/backfill-tech-steps.ts : la détection ne
tourne qu'à la création/modification d'une recette, jamais
rétroactivement — ce script recalcule le start/end/contextStart/
contextEnd de chaque étape existante contre le classifieur actuel,
pour ne pas avoir à rouvrir et resauvegarder chaque recette à la main
après un changement de corpus.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 18:15:07 +02:00
8d741ed13f feat(api): ajoute la délimitation de contexte aux tech steps et étoffe le vocabulaire du classifieur
Deux évolutions du pipeline NLP de détection des tech steps (PR #63) :

1. Délimitation de contexte — en plus du mot-clé qui déclenche un match
   (start/end), chaque TechStepMatch porte maintenant contextStart/
   contextEnd : la clause complète autour du mot-clé (ex : "poêle chaude"
   comme mot-clé, "Dans une poêle chaude" comme contexte). Persisté sur
   StepTechStep (colonnes nullables, migration dédiée), exposé via
   StepTechStepView, et rendu côté web avec un style plus discret que le
   mot-clé (StepDescription.tsx, .step-tech-step-context). splitIntoClauses
   coupe désormais sur l'espace le plus proche du milieu de l'écart entre
   deux candidats plutôt que sur le milieu brut, pour ne jamais couper un
   mot en deux (findGapSplitPoint).

2. Vocabulaire du classifieur — synonymes et locutions supplémentaires par
   technique (FR/EN) pour fiabiliser la détection sur des formulations que
   le corpus initial ne couvrait pas. Plusieurs bugs de fond trouvés et
   corrigés en cours de route, tous confirmés par la suite de tests
   complète (309 tests) :
   - un synonyme multi-mots qui est un préfixe-mot d'un synonyme plus court
     déjà enregistré pour la même technique fait matcher les deux comme
     candidats NER distincts et chevauchants, corrompant le découpage en
     clauses (parfois jusqu'à une mauvaise classification) — retiré
     partout où ce motif a été repéré (cook, fry, deglaze, simmer, boil,
     roast, chop, mince, marinate, preheat, bake, plate, coat) ;
   - "poêlé"/"poêlée" comme synonymes de panFry sont réduits à la même
     racine que le nom "poêle" par le stemmer français de node-nlp,
     provoquant un faux positif sur toute mention nue de "poêle" (dont
     celle de preheat) — retiré ;
   - "Fouetter les blancs en neige" était mal classé en foldIn (la phrase
     d'entraînement de foldIn partage la même locution) — corrigé en
     ajoutant des phrases d'entraînement dédiées à whisk ;
   - "Émincer les tomates" est passé sous le seuil de confiance vers melt
     après l'ajout du nouveau vocabulaire ailleurs dans le corpus — corrigé
     en élargissant les phrases d'entraînement de mince à un autre légume.

Le test unitaire de splitIntoClauses avec un point de coupure obsolète
(pré-datant findGapSplitPoint) est aussi corrigé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 16:42:32 +02:00
9f68c144f3 feat(api): remplace la détection des tech steps par un pipeline NLP (node-nlp)
Le matching par regex ne généralisait jamais au-delà de son propre
vocabulaire — une étape décrivant 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é.

Nouveau pipeline en 3 étapes (TechStepClassifierService, node-nlp
4.27.0 — la 5.x est encore alpha, non retenue) :
1. NER (entités enum) trouve les mentions candidates + leur position
   exacte, à partir de listes de synonymes (tech-step-training-data.ts)
   plutôt que de regex écrites à la main. ner.threshold: 1 (exact,
   après normalisation) — le défaut à 0.8 faisait matcher "faire" (verbe
   auxiliaire omniprésent) contre "frire" par pure proximité de chaîne.
2. La description est découpée en clauses autour de ces candidats
   (splitIntoClauses, pure/testable sans modèle).
3. Le NlpManager classe chaque clause individuellement, entraîné sur
   des phrases qui n'emploient jamais le verbe de la technique — c'est
   ce qui apporte la compréhension du sens. En dessous de
   CONFIDENCE_THRESHOLD (0.65, ajusté empiriquement), retombe sur la
   technique impliquée par l'ancre NER plutôt que d'abandonner un match
   clairement ancré sur un mot-clé.

TechStepMapping (table de regex par technique/locale) supprimée —
migration 20260821130000_drop_tech_step_mapping — plus aucune table
n'est interrogée à l'exécution, les données de matching vivent en code.
TECH_STEPS (reference-seed-data.ts) simplifié en simple liste de uid,
les mappings ayant disparu.

Deux pièges trouvés en construisant ce pipeline, corrigés à la source :
- db/prisma.ts construisait PrismaClient sans importer config/env.ts —
  un run de test isolé pouvait faire gagner la course au .env interne
  de Prisma (dev) contre .env.test. Fixé en important config/env.js en
  tout premier, pour effet de bord.
- NlpManager a autoSave/autoLoad: true par défaut — persiste le modèle
  entraîné dans model.nlp et le recharge au lieu de ré-entraîner au
  prochain démarrage. Les deux désactivés explicitement (sinon un
  modèle obsolète masquerait silencieusement toute mise à jour du
  corpus/seuil) ; model.nlp ajouté au .gitignore en garde-fou.

apps/api/src/db/prisma.ts, recipe.service.ts, sources.service.ts et
recipe-translation.ts adaptés à la matching async (le classifieur
entraîné remplace le couple loadTechStepMappingRules+matchTechStepSpans
synchrone) ; server.ts appelle techStepClassifier.warmUp() avant
d'accepter du trafic (le tout premier appel réel à
NlpManager.process() charge les ressources par langue de node-nlp,
plusieurs secondes).

Vérifié : tsc --noEmit, biome check (0 erreur), build complet des 6
packages, 308 tests API (dont un test-support/reset-db.ts corrigé —
référençait encore tech_step_mapping dans son TRUNCATE).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 15:29:09 +02:00
43 changed files with 7786 additions and 865 deletions

6
.gitignore vendored
View file

@ -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

View file

@ -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"
}, },

View file

@ -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";

View file

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "step_tech_step" ADD COLUMN "context_end" INTEGER,
ADD COLUMN "context_start" INTEGER;

View file

@ -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,13 +666,19 @@ 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.
@ -692,6 +688,8 @@ model StepTechStep {
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)

View file

@ -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";
/** /**

View file

@ -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

View file

@ -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. */

View file

@ -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,22 +60,28 @@ 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> {
try {
const steps = await Promise.all(
recipe.steps.map(async (step) => ({
...step,
techStepIds: await techStepClassifier.matchTechSteps(step.description, locale),
})),
);
return { return {
...recipe, ...recipe,
ingredients: recipe.ingredients.map((ingredient) => ({ ingredients: recipe.ingredients.map((ingredient) => ({
@ -86,11 +89,14 @@ export function translateRecipeSteps(
ingredientId: null, ingredientId: null,
unitId: null, unitId: null,
})), })),
steps: recipe.steps.map((step) => ({ steps,
...step,
techStepIds: matchTechSteps(step.description, techStepMappings),
})),
}; };
} 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;

View file

@ -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 = /[.!?]/;
/** /**
* Detects every technique `description` mentions among `mappings`, as an * Picks where to cut the gap `[gapStart, gapEnd)` between two consecutive
* ordered sequence of matches (each carrying *where* it matched) empty if * candidates preferring a *sentence* boundary (right after `.`/`!`/`?`)
* none match. The algorithm: * 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.
* *
* 1. Test every mapping against the normalized description; each one that * The sentence-boundary preference matters beyond cosmetics: a description
* matches becomes a candidate carrying *where* it matched (so * with two techniques in two different sentences ("Préchauffer le four à
* overlapping matches can be compared). * 180°C. Dans un saladier, mettre le beurre... et mélanger.") used to only
* 2. Within a single technique, several of its own mappings might all * get a plain nearest-midpoint whitespace split, which for a long first
* match (different phrasings for the same `techStepId`) keep only * sentence lands *inside* the second one handing the classifier a clause
* that technique's best candidate (highest weight, ties broken by * like "...(thermostat 6). Dans un saladier, mettre" that trails off
* earliest match), the same tie-break this function always used for a * mid-instruction with no object. That garbled, incomplete text is nothing
* single winner. * like the short, complete training utterances, and was found to
* 3. Across *different* techniques, two candidates can still overlap (a * misclassify real recipe steps with high (>0.65) confidence in both
* generic pattern matching inside a more specific one's span, e.g. * halves "Préchauffer..." scored as `mix`, its actual "mélanger" clause
* `cook` vs `bake` both matching "cuire au four") resolve greedily by * as `melt`. Splitting at the real sentence boundary instead hands the
* weight: take candidates highest-weight first, accept a candidate only * classifier two complete, grammatical clauses, each far closer to what it
* if it doesn't overlap one already accepted. This is what keeps * was trained on.
* `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)`, * Falls back to the raw midpoint when the gap has no whitespace at all
* used as-is against the *original* `description` by callers that slice it * (adjacent candidates, or a gap that's pure punctuation with no space)
* for display (`highlight-tech-steps.ts`, apps/web) `normalizeText` only * same "some split point, however imperfect" fallback a plain midpoint
* strips diacritics/lowercases, which preserves character count for * always was.
* 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( function findGapSplitPoint(description: string, gapStart: number, gapEnd: number): number {
description: string, if (gapStart >= gapEnd) return gapStart;
mappings: TechStepMappingRule[], const midpoint = Math.floor((gapStart + gapEnd) / 2);
): TechStepMatch[] {
const normalizedDescription = normalizeText(description);
const candidates: MatchCandidate[] = []; let bestSentenceEnd: number | null = null;
for (const mapping of mappings) { let bestSentenceEndDistance = Number.POSITIVE_INFINITY;
const pattern = new RegExp(normalizeText(mapping.expression), "i"); let bestWhitespace: number | null = null;
const match = pattern.exec(normalizedDescription); let bestWhitespaceDistance = Number.POSITIVE_INFINITY;
if (match === null) continue; for (let i = gapStart; i < gapEnd; i++) {
candidates.push({ if (!/\s/.test(description[i] ?? "")) continue;
...mapping, const distance = Math.abs(i - midpoint);
start: match.index, if (distance < bestWhitespaceDistance) {
end: match.index + match[0].length, bestWhitespace = i;
}); bestWhitespaceDistance = distance;
} }
// Step 2: one best candidate per techStepId.
const bestByTechStep = new Map<number, MatchCandidate>();
for (const candidate of candidates) {
const current = bestByTechStep.get(candidate.techStepId);
if ( if (
current === undefined || i > gapStart &&
candidate.weight > current.weight || SENTENCE_END_PATTERN.test(description[i - 1] ?? "") &&
(candidate.weight === current.weight && candidate.start < current.start) distance < bestSentenceEndDistance
) { ) {
bestByTechStep.set(candidate.techStepId, candidate); bestSentenceEnd = i;
bestSentenceEndDistance = distance;
} }
} }
return bestSentenceEnd ?? bestWhitespace ?? midpoint;
// Step 3: resolve cross-technique overlaps, highest weight first.
const byWeightDesc = [...bestByTechStep.values()].sort(
(a, b) => b.weight - a.weight || a.techStepId - b.techStepId,
);
const accepted: MatchCandidate[] = [];
for (const candidate of byWeightDesc) {
if (accepted.some((other) => overlaps(candidate, other))) continue;
accepted.push(candidate);
}
// Step 4: reading order.
accepted.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId);
return accepted.map(({ techStepId, start, end }) => ({
techStepId,
start,
end,
}));
} }
/** /**
* Convenience wrapper around {@link matchTechStepSpans} for callers that * Cuts `description` into clauses around `candidates` (NER's found
* only care about *which* techniques matched, not where e.g. * technique mentions, already sorted or not sorted internally), one
* `recipe-translation.ts`'s `translateRecipeSteps`, which declares a step's * clause per candidate, so each can be judged by the classifier on its own
* technique sequence for an imported recipe that isn't saved (and so has no * surrounding context rather than the whole (possibly multi-technique)
* `StepTechStep` row to persist a span into) yet. * description at once.
*/
export function matchTechSteps(description: string, mappings: TechStepMappingRule[]): number[] {
return matchTechStepSpans(description, mappings).map((match) => match.techStepId);
}
/**
* Loads every `TechStepMapping` row for `locale` as
* {@link TechStepMappingRule}s meant to be fetched once per request by
* `recipe.service.ts`'s `createRecipe`/`updateRecipe` and reused across
* every step of the recipe being saved, not re-queried per step.
* *
* No user-language preference exists anywhere in the app yet (a single * - **Zero candidates**: the whole description is one clause with no
* `"fr"` translation file, no locale field on `User`/`UserProfile`) * anchor still worth classifying (a description mentioning no literal
* callers pass a hardcoded locale for now; this parameter exists so that * keyword at all can still *mean* a technique, the entire point of the
* plugging in a real user preference later doesn't require touching this * classification step), just with no tight span to highlight, so callers
* module. * fall back to highlighting the whole thing.
* - **One candidate**: the whole description is one clause too (nothing to
* cut around a single mention), but *with* that candidate as its anchor
* callers get its tight span for highlighting.
* - **Two or more**: split points fall at the whitespace nearest the
* midpoint of each consecutive pair's `[end, nextStart]` gap (see
* {@link findGapSplitPoint} never mid-word), producing that many
* contiguous, non-overlapping clauses covering the whole description
* clause *i* is anchored on candidate *i*.
*
* Pure and DB/model-free unit-tested directly (see
* `test/tech-step-matcher.test.ts`) without needing a trained classifier.
*/ */
export async function loadTechStepMappingRules(locale: string): Promise<TechStepMappingRule[]> { export function splitIntoClauses(
try { description: string,
return await prisma.techStepMapping.findMany({ candidates: TechniqueCandidate[],
where: { locale }, ): TechStepClause[] {
select: { techStepId: true, expression: true, weight: true }, if (candidates.length === 0) {
return [{ start: 0, end: description.length, anchor: null }];
}
const sorted = [...candidates].sort((a, b) => a.start - b.start);
const [first, ...rest] = sorted;
if (first === undefined) {
// Unreachable — `candidates.length === 0` already returned above, so
// `sorted` (same length) always has a first element here. Satisfies
// `noUncheckedIndexedAccess`, which can't see that from the length
// check alone.
return [{ start: 0, end: description.length, anchor: null }];
}
// 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,
}); });
}
/**
* Forces training plus node-nlp's own one-time lazy setup (loading its
* bundled per-language stemmers/tokenizers on the *first* real
* `NlpManager.process()` call takes a few seconds by itself, separate
* from and much slower than the ~40ms `train()` pass measured against
* this corpus while tuning the pipeline) to happen now, synchronously
* with server startup (see `server.ts`), rather than stalling whichever
* request happens to be first to save/preview a recipe.
*/
public async warmUp(): Promise<void> {
try {
await this.matchTechStepSpans("faire cuire à feu doux", "fr");
} catch (err) { } catch (err) {
// Rethrown as-is — the caller (`recipe.service.ts`/`sources.service.ts`) throw err; // see matchTechStepSpans()'s catch comment above
// already handles/logs failures centrally; this function just isn't }
// allowed a bare `async` body without a try/catch per the repo's }
// convention.
/**
* Detects every technique `description` means, as an ordered sequence of
* matches (each carrying *where* it matched) empty if none apply. See
* this file's doc comment for the full NER -> split -> classify
* pipeline.
*
* @param locale Which of `TECH_STEP_TRAINING_DATA`'s locales to match
* 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; throw err;
} }
}
/**
* Convenience wrapper around {@link matchTechStepSpans} for callers that
* only care about *which* techniques matched, not where e.g.
* `recipe-translation.ts`'s `translateRecipeSteps`, which declares a
* step's technique sequence for an imported recipe that isn't saved (and
* 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
}
}
/**
* Classifies one clause, returning the technique `uid` it means (or
* `null` if none applies) the classifier's own verdict when it's
* confident enough ({@link CONFIDENCE_THRESHOLD}), otherwise the
* clause's NER anchor (if it has one) as a floor: a clearly
* keyword-anchored clause a small model merely isn't sure how to
* classify shouldn't be dropped outright, only a genuinely
* anchor-less/low-confidence one should.
*/
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);
if (result.intent !== "None" && result.score >= CONFIDENCE_THRESHOLD) {
return result.intent;
}
return clause.anchor?.uid ?? null;
} catch (err) {
throw err; // see matchTechStepSpans()'s catch comment above
}
}
/**
* Trains `_manager` from {@link TECH_STEP_TRAINING_DATA} and resolves the
* `uid -> TechStep.id` lookup, both exactly once memoized on
* `_trained` so a burst of concurrent calls (several steps of the same
* recipe save, awaited via the same event loop tick) all await the one
* in-flight training pass rather than each kicking off their own.
*/
private async _ensureTrained(): Promise<void> {
if (this._trained === undefined) {
this._trained = this._train();
}
try {
await this._trained;
} 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();

View 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",
],
},
},
];

View file

@ -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.
*/ */

View file

@ -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,
contextEnd: match.contextEnd,
order, 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,
contextEnd: match.contextEnd,
order, order,
}), })),
),
}, },
})), })),
}, },

View file

@ -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,8 +169,14 @@ 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([
Promise.all(
parsed.steps.map(async (step) => ({
step,
matches: await techStepClassifier.matchTechStepSpans(step.description, adapter.locale),
})),
),
adapter.locale === "en" adapter.locale === "en"
? loadIngredientCatalog() ? loadIngredientCatalog()
: Promise.resolve<IngredientMatchEntry[]>([]), : Promise.resolve<IngredientMatchEntry[]>([]),
@ -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,
},
]
: [];
}), }),
})); }));

View 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);
});

View file

@ -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
View 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>;
}
}

View file

@ -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;

View file

@ -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([]);
}); });

View file

@ -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");
expect(deRules).to.deep.equal([
{ techStepId: simmer.id, expression: "\\bsimmer\\b", weight: 15 },
]); ]);
}); });
it("returns an empty list for a locale with no mappings at all", async () => { it("returns an empty sequence when nothing matches", async () => {
expect(await loadTechStepMappingRules("de")).to.deep.equal([]); expect(
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]);
});
});
describe("matchTechStepSpans", () => {
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");
});
}); });
}); });
}); });

View file

@ -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);
}); });
}); });

View file

@ -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 }]);
});
});
}); });

View file

@ -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;

View file

@ -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

View file

@ -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;
} }

View file

@ -0,0 +1,3 @@
node_modules/
dist/
models/

View file

@ -0,0 +1,210 @@
# PoC — détection d'actions culinaires : NLP, LLM local, hybride
Expérimentation autonome, **hors du monorepo pnpm** (`pnpm-workspace.yaml` ne
référence que `apps/*`/`packages/*`) : ce dossier a son propre
`package.json`/`tsconfig.json` et ne pollue ni les dépendances ni le build
Docker de `apps/api`.
Objectif : comparer, sur la même tâche (structurer une étape de recette en
séquence ordonnée d'actions culinaires) et le même jeu de 11 phrases, quatre
moteurs qui tournent tous 100 % en local :
| Script | Moteur | Ce qu'il apporte |
|---|---|---|
| `pnpm bench` | Mini LLM instruct via [`node-llama-cpp`](https://node-llama-cpp.withcat.ai/) (binding natif **dans ce process**), sortie JSON contrainte par schéma (GBNF grammar) | Généralise sans vocabulaire fixé à l'avance — au prix d'une latence de plusieurs secondes. |
| `pnpm bench:ollama` | Le même LLM (même `SYSTEM_PROMPT`, même tâche), mais via [Ollama](https://ollama.com/) — un serveur HTTP **local séparé** plutôt qu'un binding embarqué | Compare l'impact de l'architecture (client-serveur vs in-process) à sémantique identique, pas juste un autre modèle. |
| `pnpm bench:nlp` | Classifieur `node-nlp` frais (NER + découpage en clauses + classification d'intention), entraîné directement sur la taxonomie à 7 catégories de ce PoC | Rapide (centaines de ms), mais borné à son vocabulaire d'entraînement. |
| `pnpm bench:hybrid` | NLP d'abord, LLM (`node-llama-cpp`) en secours si le score NLP est trop faible | Le meilleur des deux : rapide sur le cas courant, généralise sur le cas difficile. |
Les quatre partagent le même code (`src/shared/`) : la taxonomie
`KitchenActionType`/`KitchenAction`/`RecipeStepAnalysis`
(`shared/kitchen-action.ts`), les 11 phrases de test
(`shared/test-sentences.ts`), et le harness de mesure/affichage
(`shared/benchmark-harness.ts`) — un seul jeu de phrases et un seul format
de sortie pour que les runs soient directement comparables, plutôt que
recopiés à la main dans chaque script (le défaut d'une toute première
version de ce PoC, où le pendant NLP vivait dans `apps/api` et copiait les
phrases manuellement). Les deux moteurs LLM (`node-llama-cpp`/Ollama)
partagent en plus le `SYSTEM_PROMPT` lui-même (exporté par
`llm-tech-step-poc.ts`, importé par `ollama-tech-step-poc.ts`) — même
sémantique testée sur les deux, seul le mécanisme de contrainte JSON change.
## Installation
```bash
cd experiments/llm-tech-step-poc
pnpm install --ignore-workspace
```
`--ignore-workspace` est nécessaire : ce dossier n'étant pas dans les globs
de `pnpm-workspace.yaml` (`apps/*`/`packages/*`), un `pnpm install` normal
remonte jusqu'à la racine du monorepo et n'installe **rien** ici (aucune
erreur, juste un `node_modules` vide/inutilisable) — piège trouvé en écrivant
ce PoC.
`node-llama-cpp` télécharge/compile son binding natif llama.cpp à
l'installation (binaire prébuilt pour les plateformes courantes, sinon
compilation locale — nécessite alors un toolchain C++, voir sa doc
["Troubleshooting"](https://node-llama-cpp.withcat.ai/guide/troubleshooting)
en cas d'échec). `ollama` (le paquet npm) n'a lui aucune dépendance
native — c'est un simple client HTTP, rien à compiler.
## 1. Benchmark LLM seul — `pnpm bench`
```bash
pnpm bench
```
Télécharge le modèle GGUF choisi via `LLM_TECH_STEP_MODEL` (une seule fois,
mis en cache dans `experiments/llm-tech-step-poc/models/`, jamais commité),
le charge, fait un appel de warm-up (chronométré à part — le tout premier
appel d'inférence sur un contexte fraîchement créé paie un coût caché de
plusieurs secondes que le chargement du modèle ne couvre pas), puis lance 3
répétitions sur chacune des 7 phrases de test.
| Valeur (défaut en gras) | Modèle | Pourquoi |
|---|---|---|
| **`qwen2.5-1.5b`** | Qwen2.5-1.5B-Instruct, `Q4_K_M` | Meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON — et, empiriquement (voir `Résultats obtenus` ci-dessous), aussi la latence la plus basse des deux sur ce benchmark, malgré ses ~50 % de paramètres en plus. |
| `llama-3.2-1b` | Llama-3.2-1B-Instruct, `Q4_K_M` | ~35 % de paramètres en moins, FR officiellement supporté, mais structuration JSON moins fiable à 1B — et pas plus rapide non plus dans les runs obtenus jusqu'ici. Conservé comme point de comparaison, pas comme choix "latence d'abord". |
> **Résultats obtenus** (Windows, backend Vulkan, une machine) : sur les 7
> phrases, Qwen2.5-1.5B a été systématiquement plus rapide que Llama-3.2-1B
> malgré sa taille plus grande — l'inverse de l'hypothèse a priori "moins de
> paramètres = plus rapide". Un seul run sur une seule machine/un seul
> backend ne généralise pas forcément (CUDA/CPU pur donneraient
> possiblement un classement différent).
```bash
LLM_TECH_STEP_MODEL=llama-3.2-1b pnpm bench
```
**Hors-ligne / CI** : `LLM_TECH_STEP_MODEL_PATH=/chemin/vers/un.gguf pnpm bench`
pointe directement vers un fichier déjà téléchargé, sans passer par la
résolution/téléchargement Hugging Face.
## 2. Benchmark LLM via Ollama — `pnpm bench:ollama`
Prérequis : [Ollama](https://ollama.com/download) installé, **et son serveur
lancé** (`ollama serve` dans un terminal séparé, ou l'app de bureau Ollama
qui le lance automatiquement) — ce script ne démarre pas le serveur
lui-même, contrairement à `pnpm bench` qui charge son modèle directement.
```bash
ollama serve # si pas déjà lancé (ou l'app de bureau Ollama)
pnpm bench:ollama
```
Même tâche, même `SYSTEM_PROMPT`, mêmes modèles (`OLLAMA_TECH_STEP_MODEL`,
mêmes valeurs `qwen2.5-1.5b`/`llama-3.2-1b` que `LLM_TECH_STEP_MODEL`) que
la section 1 — mais une **implémentation architecturalement différente**,
testée pour ça plutôt que comme une simple redite :
| | `node-llama-cpp` (section 1) | Ollama (cette section) |
|---|---|---|
| Où tourne l'inférence | Dans CE process Node (binding natif) | Dans le process `ollama serve`, séparé |
| Installation | Binaire natif compilé/téléchargé au `pnpm install` | Client HTTP pur, rien à compiler |
| Gestion du modèle | `resolveModelFile` télécharge le GGUF dans `models/` de ce projet | `ollama pull` — Ollama gère son propre cache (`~/.ollama/models`) |
| Schéma JSON nullable | `oneOf: [{type:"null"}, {type:"..."}]` (contrainte de la grammaire GBNF) | `type: ["string", "null"]` (JSON Schema standard, plus simple) |
| Mesure RSS du benchmark | Fiable — le binding alloue dans ce process | **Sans intérêt** — l'inférence tourne ailleurs, voir ci-dessous |
**La colonne `RSS moy.` du récapitulatif ne veut RIEN dire pour ce script** —
`process.memoryUsage()` mesure ce process Node, pas le process `ollama
serve` où l'inférence a réellement lieu. Le script l'affiche quand même
(même harness que les trois autres) mais rappelle ce point juste avant le
tableau.
```bash
OLLAMA_TECH_STEP_MODEL=llama-3.2-1b pnpm bench:ollama
```
**Hôte Ollama personnalisé** (serveur distant, port non standard) :
`OLLAMA_TECH_STEP_HOST=http://mon-serveur:11434 pnpm bench:ollama`
(défaut : `http://127.0.0.1:11434`).
## 3. Benchmark NLP seul — `pnpm bench:nlp`
```bash
pnpm bench:nlp
```
Aucun téléchargement, aucune base de données — tourne en quelques secondes.
`NlpTechStepClassifier` (`src/nlp-tech-step-poc.ts`) est un classifieur
`node-nlp` **frais**, écrit pour ce PoC plutôt qu'une réutilisation de
`TechStepClassifierService` (`apps/api/src/lib/recipe-matching/
tech-step-matcher.ts`) — deux raisons :
1. **Comparaison vraiment terme à terme** : `TechStepClassifierService`
classe sur la taxonomie fine à ~26 techniques de
`tech-step-training-data.ts` (DB-backed), pas sur les 7 catégories de
`KitchenActionType` que le LLM produit — les nombres de détections
n'étaient pas directement comparables. Ce classifieur-ci est entraîné
directement sur les 7 mêmes catégories.
2. **Un score de confiance exploitable** : `TechStepClassifierService`
masque son score en retombant silencieusement sur l'ancre NER dès qu'il
est sous son seuil interne — utile en prod, mais ça cache le signal dont
le pipeline hybride (section 3) a besoin pour décider quand basculer
vers le LLM. Ce classifieur-ci renvoie toujours le score BRUT.
Même pipeline NER → découpage en clauses → classification par clause que
`tech-step-matcher.ts`, implémentation propre à ce PoC (simplifiée : pas de
priorité aux frontières de phrase dans le découpage). Le corpus
d'entraînement (`TRAINING_DATA` dans `nlp-tech-step-poc.ts`) préfère un
synonyme mono-mot ("revenir") à une phrase figée ("faites revenir") quand
c'est possible — une leçon tirée d'un run antérieur de ce PoC : "faites
revenir" (2 mots) ratait "faites-**les**-revenir" (le pronom clitique
français insère un mot entre les deux et casse un matching de phrase
contiguë), un synonyme mono-mot matche quel que soit ce qui le précède.
## 4. Pipeline hybride — `pnpm bench:hybrid`
```bash
pnpm bench:hybrid
```
Combine les deux : le NLP analyse TOUJOURS en premier (chemin rapide) ; si
sa confiance globale (le minimum de confiance de ses clauses) est sous
`NLP_TRUST_THRESHOLD` (`0.6`, tunable dans `hybrid-tech-step-poc.ts`) ou
qu'il n'a rien trouvé du tout, son résultat est ENTIÈREMENT écarté et
l'étape est réanalysée par le LLM. Le récapitulatif affiche, par phrase,
quel moteur a répondu (`moteur`) et la confiance NLP qui a déclenché la
décision (`confiance NLP`) — de quoi ajuster le seuil en observant sur
quelles phrases le pipeline bascule.
Nécessite le modèle LLM (même téléchargement/options `LLM_TECH_STEP_MODEL`/
`LLM_TECH_STEP_MODEL_PATH` que la section 1) puisqu'il reste le moteur de
secours.
**Limite assumée** : quand le chemin NLP est pris, seuls `action`/`verb`
sont réellement connus — `ingredients`/`utensils` restent `[]` et
`durationMinutes`/`temperature` restent `null`, jamais inventés (le
classifieur NLP ne peut structurellement pas les extraire). Seul le chemin
LLM remplit tous les champs. Un vrai système hybride ferait probablement
remonter le champ `source` jusqu'à l'UI pour ne promettre que ce que chaque
chemin fournit réellement.
## Limites de ce PoC
- Pas de jeu d'évaluation étiqueté ni de métrique de précision automatisée
— les 11 phrases sont inspectées à l'œil, pas notées.
- La contrainte JSON (grammaire GBNF ou JSON Schema Ollama) ne garantit
qu'une syntaxe JSON conforme au schéma, jamais la justesse sémantique du
contenu.
- Le corpus du classifieur NLP frais est volontairement compact (PoC, pas un
remplacement du corpus production `tech-step-training-data.ts`) — des
formes non couvertes (conjugaisons, synonymes absents) manqueront, comme
pour n'importe quel corpus fini.
- `NLP_TRUST_THRESHOLD` (`0.6`) est un point de départ raisonnable, pas une
valeur empiriquement optimisée — à ajuster en observant la colonne
`moteur` du récapitulatif hybride sur des étapes réelles.
- Le delta de RSS process est une approximation de la RAM réellement utilisée
pour `node-llama-cpp`/`node-nlp` (un binding natif alloue dans le même
process, donc le RSS la capture, mais au bruit du GC/de l'allocateur près)
— pas une mesure isolée, et carrément **sans valeur** pour `bench:ollama`
(l'inférence tourne dans `ollama serve`, un process séparé, voir la
section 2).
- Latence `node-llama-cpp` mesurée en CPU pur (pas de configuration GPU dans
ce PoC) — un déploiement réel voudrait évaluer l'offload GPU (`gpuLayers`
dans les options `loadModel`) si la cible dispose d'un GPU. Ollama, lui,
détecte et utilise l'accélération matérielle disponible automatiquement —
une différence qui peut à elle seule expliquer un écart de latence entre
les deux moteurs LLM, indépendamment du modèle choisi.

View file

@ -0,0 +1,30 @@
{
"name": "llm-tech-step-poc",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "PoC autonome : quatre moteurs de détection d'actions culinaires dans une étape de recette (LLM local via node-llama-cpp, le même LLM via Ollama, classifieur node-nlp frais, pipeline hybride NLP+LLM), benchmarkés sur le même jeu de phrases.",
"scripts": {
"bench": "tsx src/llm-tech-step-poc.ts",
"bench:ollama": "tsx src/ollama-tech-step-poc.ts",
"bench:nlp": "tsx src/nlp-tech-step-poc.ts",
"bench:hybrid": "tsx src/hybrid-tech-step-poc.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"node-llama-cpp": "^3.20.0",
"node-nlp": "4.27.0",
"ollama": "^0.6.3"
},
"devDependencies": {
"@types/node": "^22.9.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"node-llama-cpp"
]
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,243 @@
/**
* PoC autonome pipeline HYBRIDE combinant le classifieur `node-nlp` frais
* de `nlp-tech-step-poc.ts` (rapide, ~26 fois moins gourmand en latence
* mesuré dans les runs précédents de ce PoC) et le LLM local de
* `llm-tech-step-poc.ts` (plus lent, mais qui généralise mieux sur les
* phrases le NLP échoue franchement voir les cas piège de
* `shared/test-sentences.ts`).
*
* Principe "fast path, escalade sur signal faible" :
*
* 1. Le NLP analyse l'étape en premier, TOUJOURS (chemin rapide, quelques
* centaines de ms).
* 2. Sa {@link NlpStepAnalysis.overallConfidence} (le score BRUT, jamais
* masqué voir la doc de `nlp-tech-step-poc.ts`) est comparée à
* {@link NLP_TRUST_THRESHOLD}.
* 3. Score suffisant ET au moins une action trouvée -> le résultat NLP est
* gardé tel quel (`source: "nlp"`).
* 4. Score insuffisant (ou aucune action trouvée du tout) -> le résultat
* NLP est ENTIÈREMENT écarté, l'étape est réanalysée par le LLM
* (`source: "llm"`), plus lent mais dont ce PoC a déjà montré qu'il
* généralise mieux sur les phrases le NLP échoue (actions
* implicites, pronoms clitiques cassant un matching de phrase, etc.).
*
* Limite assumée du PoC : le résultat NLP ne porte QUE `action`/`verb`
* (voir `NlpTechStepClassifier`, structurellement incapable d'extraire
* ingrédients/durée/température/ustensiles) quand le chemin NLP est pris,
* les autres champs de {@link KitchenAction} restent vides/`null`, jamais
* inventés. Le chemin LLM, lui, remplit tous les champs. C'est un compromis
* délibéré "rapide-et-grossier vs. lent-et-riche", pas un défaut à corriger
* un vrai système hybride ferait probablement remonter le champ
* `source` jusqu'à l'UI pour ne promettre que ce que chaque chemin fournit
* réellement.
*
* Usage :
*
* ```bash
* cd experiments/llm-tech-step-poc
* pnpm install --ignore-workspace
* pnpm bench:hybrid
* ```
*/
import { performance } from "node:perf_hooks";
import {
LocalLlmStepAnalyzer,
RECOMMENDED_MODELS,
type RecommendedModelKey,
} from "./llm-tech-step-poc.js";
import { type NlpStepAnalysis, NlpTechStepClassifier } from "./nlp-tech-step-poc.js";
import {
type BenchmarkSample,
printSummaryTable,
runBenchmark,
} from "./shared/benchmark-harness.js";
import type { KitchenAction } from "./shared/kitchen-action.js";
import { isMainModule } from "./shared/module-entry.js";
import type { BenchmarkSentence } from "./shared/test-sentences.js";
/**
* Seuil de confiance NLP en dessous duquel une étape est réanalysée par le
* LLM plutôt que de garder le résultat NLP. Paramètre PROPRE à ce PoC
* délibérément plus permissif que `CONFIDENCE_THRESHOLD` de
* `tech-step-matcher.ts` (`0.75`, empiriquement ajusté contre son propre
* corpus de production) : ce classifieur-ci a un corpus bien plus compact
* (voir `nlp-tech-step-poc.ts`), un seuil aussi strict escaladerait presque
* tout vers le LLM et ne testerait jamais vraiment le chemin rapide. `0.6`
* est un point de départ raisonnable pour un PoC, pas une valeur
* empiriquement optimisée à ajuster en observant le récapitulatif
* (colonne `moteur`) sur des étapes réelles.
*/
const NLP_TRUST_THRESHOLD = 0.6;
/** Quel moteur a produit le résultat final pour une étape. */
export type HybridSource = "nlp" | "llm";
/** Résultat du pipeline hybride pour une étape — mêmes `actions` que les deux autres moteurs, plus la traçabilité de quel chemin a été pris et pourquoi. */
export interface HybridStepAnalysis {
originalText: string;
source: HybridSource;
/** Confiance globale renvoyée par le NLP — calculée et conservée MÊME quand le LLM finit par traiter l'étape, pour que le benchmark montre ce qui a déclenché l'escalade. */
nlpConfidence: number;
actions: KitchenAction[];
}
/** Convertit les matches du classifieur NLP en `KitchenAction[]` — seuls `action`/`verb` sont réellement connus, voir le doc-comment en tête de fichier. */
function toKitchenActions(nlpResult: NlpStepAnalysis): KitchenAction[] {
return nlpResult.matches.map((match) => ({
action: match.action,
verb: match.matchedText,
ingredients: [],
durationMinutes: null,
temperature: null,
utensils: [],
}));
}
/**
* Combine {@link NlpTechStepClassifier} et {@link LocalLlmStepAnalyzer}
* derrière une seule méthode `analyzeStep` vraie `class` (pas un objet
* littéral), même convention que les deux moteurs qu'elle orchestre : elle
* possède un état réel (les deux moteurs sous-jacents), pas juste des
* fonctions groupées sans état.
*/
export class HybridStepAnalyzer {
private readonly _nlp: NlpTechStepClassifier;
private readonly _llm: LocalLlmStepAnalyzer;
public constructor() {
this._nlp = new NlpTechStepClassifier();
this._llm = new LocalLlmStepAnalyzer();
}
/** Initialise le LLM (chargement du modèle) — le NLP n'a pas de phase d'initialisation séparée, son entraînement est mémoïsé au premier appel (voir `NlpTechStepClassifier`). */
public async initialize(modelKey: RecommendedModelKey): Promise<void> {
await this._llm.initialize(modelKey);
}
/** Warm-up des deux moteurs — voir la doc de chacun (`NlpTechStepClassifier.warmUp`/`LocalLlmStepAnalyzer.warmUp`) pour pourquoi c'est nécessaire séparément du benchmark. */
public async warmUp(): Promise<void> {
await this._nlp.warmUp();
await this._llm.warmUp();
}
/**
* Analyse une étape : NLP d'abord (toujours), LLM seulement si le score
* NLP est sous {@link NLP_TRUST_THRESHOLD} ou qu'aucune action n'a é
* trouvée du tout voir le doc-comment en tête de fichier pour le détail
* de la logique de décision.
*/
public async analyzeStep(sentence: BenchmarkSentence): Promise<HybridStepAnalysis> {
const nlpResult = await this._nlp.analyzeStep(sentence.text, sentence.locale);
const nlpIsTrustworthy =
nlpResult.overallConfidence >= NLP_TRUST_THRESHOLD && nlpResult.matches.length > 0;
if (nlpIsTrustworthy) {
return {
originalText: sentence.text,
source: "nlp",
nlpConfidence: nlpResult.overallConfidence,
actions: toKitchenActions(nlpResult),
};
}
const llmResult = await this._llm.analyzeStep(sentence.text);
return {
originalText: sentence.text,
source: "llm",
nlpConfidence: nlpResult.overallConfidence,
actions: llmResult.actions,
};
}
/** Libère le LLM (le NLP n'a pas de ressource native à libérer). */
public async dispose(): Promise<void> {
await this._llm.dispose();
}
}
// ---------------------------------------------------------------------------
// Benchmark
// ---------------------------------------------------------------------------
/** Imprime le détail de chaque échantillon — quel moteur a répondu, avec quelle confiance NLP, et les actions obtenues. */
function printDetailedResults(samples: readonly BenchmarkSample<HybridStepAnalysis>[]): void {
for (const sample of samples) {
console.info(
`\n[${sample.sentence.id}] (${sample.sentence.locale}) — ${sample.latencyMs.toFixed(0)} ms, moteur: ${sample.result.source} (confiance NLP ${sample.result.nlpConfidence.toFixed(2)})`,
);
console.info(` texte : ${sample.sentence.text}`);
console.info(` attendu : ${sample.sentence.note}`);
console.table(
sample.result.actions.map((action) => ({
action: action.action,
verbe: action.verb,
ingrédients: action.ingredients.join(", "),
"durée (min)": action.durationMinutes ?? "—",
température: action.temperature ?? "—",
ustensiles: action.utensils.join(", "),
})),
);
}
}
async function main(): Promise<void> {
const modelKey: RecommendedModelKey =
process.env.LLM_TECH_STEP_MODEL === "llama-3.2-1b" ? "llama-3.2-1b" : "qwen2.5-1.5b";
console.info(
`[hybrid] modèle LLM de secours : ${modelKey} (${RECOMMENDED_MODELS[modelKey].rationale})`,
);
console.info(`[hybrid] seuil de confiance NLP : ${NLP_TRUST_THRESHOLD}`);
const analyzer = new HybridStepAnalyzer();
try {
console.info("[hybrid] initialisation (chargement du modèle LLM de secours)...");
await analyzer.initialize(modelKey);
} catch (err) {
console.error("[hybrid] échec de l'initialisation", err);
process.exitCode = 1;
return;
}
const warmUpStartedAt = performance.now();
try {
await analyzer.warmUp();
} catch (err) {
console.error("[hybrid] échec du warm-up — le benchmark continue quand même", err);
}
console.info(`[hybrid] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`);
try {
const benchmarkStartedAt = performance.now();
const samples = await runBenchmark<HybridStepAnalysis>({
logPrefix: "[hybrid]",
countOf: (result) => result.actions.length,
countLabel: "action(s) détectée(s)",
analyze: (sentence) => analyzer.analyzeStep(sentence),
});
console.info(
`[hybrid] benchmark complet en ${(performance.now() - benchmarkStartedAt).toFixed(0)} ms`,
);
printDetailedResults(samples);
printSummaryTable(samples, (result) => result.actions.length, "actions détectées", [
{
label: "moteur",
valueOf: (lastSample) => lastSample.result.source,
},
{
label: "confiance NLP",
valueOf: (lastSample) => lastSample.result.nlpConfidence.toFixed(2),
},
]);
} finally {
try {
await analyzer.dispose();
} catch (err) {
console.error("[hybrid] erreur lors de la libération des moteurs", err);
}
}
}
if (isMainModule(import.meta.url)) {
await main();
}

View file

@ -0,0 +1,445 @@
/**
* PoC autonome détection d'actions culinaires via un mini LLM local
* (`node-llama-cpp`), à comparer au classifieur `node-nlp` frais de
* `nlp-tech-step-poc.ts` (et, au-delà, au pipeline `node-nlp` de production
* dans `apps/api/src/lib/recipe-matching/tech-step-matcher.ts`).
*
* Ce PoC teste une approche différente d'un classifieur par clause : demander
* à un petit LLM instruct local d'extraire en une seule passe la séquence
* ORDONNÉE de toutes les actions atomiques d'une étape, sous forme d'un JSON
* structuré sans vocabulaire fixé à l'avance, au prix d'une latence et
* d'une empreinte mémoire bien plus élevées (un modèle de ~1 à 2 Md de
* paramètres contre un classifieur NLP léger). Tourne entièrement en local,
* sans appel réseau à l'inférence (le seul accès réseau de ce fichier est le
* téléchargement ponctuel du modèle GGUF, voir {@link resolveModelPath}).
*
* Portée volontairement limitée à un fichier autonome, hors du monorepo
* pnpm (`pnpm-workspace.yaml` ne référence que `apps/*`/`packages/*`) :
* c'est un script d'expérimentation jetable, pas un module destiné à être
* consommé par `apps/api` même statut que les scripts one-off déjà
* exemptés de la convention "un `try`/`catch` par `await`" du repo
* (`prisma/seed.ts`, `apps/api/src/scripts/seed-runtime.ts`) : ici aussi,
* laisser une erreur se propager telle quelle jusqu'au point d'appel qui
* décide quoi en faire (le run complet du benchmark, ou `main()` en tout
* dernier ressort) est plus lisible qu'un `catch { throw err; }` répété
* sans rien y ajouter.
*
* Usage : voir `README.md` à côté de ce fichier (installation, modèle,
* variables d'environnement). En bref :
*
* ```bash
* cd experiments/llm-tech-step-poc
* pnpm install --ignore-workspace
* pnpm bench
* ```
*/
import path from "node:path";
import { performance } from "node:perf_hooks";
import { fileURLToPath } from "node:url";
import {
getLlama,
LlamaChatSession,
type LlamaJsonSchemaGrammar,
resolveModelFile,
} from "node-llama-cpp";
import {
type BenchmarkSample,
printSummaryTable,
runBenchmark,
} from "./shared/benchmark-harness.js";
import { KitchenActionType, type RecipeStepAnalysis } from "./shared/kitchen-action.js";
import { isMainModule } from "./shared/module-entry.js";
import type { BenchmarkSentence } from "./shared/test-sentences.js";
// ---------------------------------------------------------------------------
// Schéma JSON — grammaire GBNF imposée à la génération
// ---------------------------------------------------------------------------
/**
* Schéma JSON d'une action, dans le sous-ensemble supporté par
* `LlamaChatSession`+`llama.createGrammarForJsonSchema` (object/array/
* string/number/enum/oneOf pas d'union `type: [...]` pour les champs
* nullable, node-llama-cpp veut `oneOf: [{type:"null"}, {type:"..."}]`,
* voir la doc "Using Grammar"). Champ à champ, en miroir strict de
* `KitchenAction` (`shared/kitchen-action.ts`) : la grammaire ne fait
* qu'imposer une SYNTAXE JSON valide conforme à ce schéma, elle ne garantit
* pas que le modèle choisisse la bonne catégorie/le bon champ c'est le
* rôle du prompt système ({@link SYSTEM_PROMPT}) de guider la sémantique.
*/
const KITCHEN_ACTION_JSON_SCHEMA = {
type: "object",
properties: {
action: { enum: Object.values(KitchenActionType) },
verb: { type: "string" },
ingredients: { type: "array", items: { type: "string" } },
durationMinutes: { oneOf: [{ type: "null" }, { type: "number" }] },
temperature: { oneOf: [{ type: "null" }, { type: "string" }] },
utensils: { type: "array", items: { type: "string" } },
},
required: ["action", "verb", "ingredients", "durationMinutes", "temperature", "utensils"],
} as const;
/**
* Racine du schéma imposé au modèle un objet `{ actions: [...] }` plutôt
* qu'un tableau nu en racine (node-llama-cpp exige un `type: "object"` en
* racine de la grammaire JSON). `originalText` n'y figure pas : le faire
* recopier le texte d'entrée gaspillerait des tokens de génération et
* risquerait une recopie légèrement différente de l'original (espaces,
* ponctuation) sans aucun bénéfice ce champ est réattaché
* programmatiquement par {@link LocalLlmStepAnalyzer.analyzeStep} à partir
* de l'argument d'entrée, pas de la réponse du modèle.
*/
const KITCHEN_ACTIONS_JSON_SCHEMA = {
type: "object",
properties: {
actions: { type: "array", items: KITCHEN_ACTION_JSON_SCHEMA },
},
required: ["actions"],
} as const;
/** Forme brute que renvoie `grammar.parse()` pour {@link KITCHEN_ACTIONS_JSON_SCHEMA} — reconverti en {@link RecipeStepAnalysis} par {@link LocalLlmStepAnalyzer.analyzeStep}. */
interface KitchenActionsGrammarResult {
actions: RecipeStepAnalysis["actions"];
}
/**
* Instructions système porte toute la sémantique que la grammaire GBNF ne
* peut pas imposer (elle ne contraint que la forme JSON, jamais le
* contenu) : la définition de chaque catégorie de {@link KitchenActionType},
* et ce qu'extraire pour chaque champ. Explicitement bilingue dans son
* énoncé même (plutôt que deux prompts FR/EN séparés à maintenir) le but
* du benchmark est justement de voir si un seul prompt, sur un modèle
* multilingue, tient la route en français ET en anglais sans bascule
* explicite de langue.
*
* Exporté et réutilisé tel quel par `ollama-tech-step-poc.ts` les deux
* moteurs LLM de ce PoC doivent tester exactement la même sémantique/tâche,
* seul le mécanisme de contrainte JSON (grammaire GBNF ici, JSON Schema
* natif côté Ollama) diffère ; dupliquer ce texte risquerait de faire
* dériver les deux prompts sans que ce soit voulu.
*/
export const SYSTEM_PROMPT = `You are a culinary instruction parser. You receive ONE recipe step, written in either French or English. Break it down into the ordered sequence of atomic actions it describes, and respond with ONLY the JSON object required by the schema — no prose, no markdown code fences, no explanation.
Action taxonomy (pick exactly one per action):
- CUT: knife work chopping, dicing, mincing, slicing, peeling.
- COOK: applying heat to actually cook food frying, sautéing, simmering, boiling, baking, grilling, melting.
- MIX: combining/stirring/whisking/folding ingredients together, with no heat involved.
- REST: letting something sit, cool, chill, marinate or rise without active handling.
- SEASON: adding salt, pepper, spices, herbs or condiments to flavor a dish.
- PREHEAT: bringing an oven, pan or appliance up to temperature before it is used.
- OTHER: anything not covered above (plating, straining, transferring, reserving...).
For each action extract:
- verb: the literal action verb from the source text, in its original language.
- ingredients: the ingredients this specific action applies to (empty array if none named).
- durationMinutes: a single number in minutes if a duration is stated (convert hours/seconds), otherwise null.
- temperature: the literal temperature/heat-level mention (e.g. "180°C", "feu doux", "medium heat"), otherwise null.
- utensils: any cookware/tools named for this action (empty array if none named).
Keep the actions in the order they happen in the text. A step can describe several sequential actions, even when no explicit verb names a technique (e.g. "until the butter has disappeared into the pan" means melting butter, i.e. COOK).`;
// ---------------------------------------------------------------------------
// Modèles recommandés
// ---------------------------------------------------------------------------
/** Une des deux familles de modèles GGUF évaluées par ce PoC — voir le comparatif dans `README.md`. */
export type RecommendedModelKey = "qwen2.5-1.5b" | "llama-3.2-1b";
/** Un modèle GGUF candidat, référencé par son URI `hf:` (résolu/téléchargé par `resolveModelFile`, voir la doc "Downloading Models" de node-llama-cpp). */
interface RecommendedModel {
/** URI `hf:<repo>:<quant>` — node-llama-cpp résout et télécharge (une seule fois, mis en cache) le fichier GGUF correspondant depuis Hugging Face. */
hfUri: string;
/** Pourquoi ce modèle, en une phrase — voir aussi le comparatif détaillé dans `README.md`. */
rationale: string;
}
/**
* Les deux modèles recommandés pour cette tâche, choisis parmi les
* instruct GGUF ~1-1.5 Md de paramètres (assez petits pour tourner en CPU
* pur avec une latence de l'ordre de la seconde, assez récents pour bien
* suivre des instructions de structuration JSON) :
*
* - **Qwen2.5-1.5B-Instruct** (recommandation par défaut) : corpus
* d'entraînement nettement plus multilingue que la famille Llama à
* taille comparable, et meilleur suivi d'instructions de structuration
* (extraction JSON, function calling) dans les benchmarks publiés par
* Qwen. Contrairement à l'hypothèse initiale ("plus de paramètres, donc
* plus lent"), des runs antérieurs de ce PoC (Windows, backend Vulkan)
* l'ont aussi montré systématiquement PLUS RAPIDE que Llama-3.2-1B sur
* les 7 phrases de test, malgré ses ~50 % de paramètres en plus le
* premier choix sur les deux axes ici, pas seulement sur la robustesse
* FR/EN.
* - **Llama-3.2-1B-Instruct** (alternative) : ~35 % de paramètres en
* moins, et le FR fait partie de ses langues officiellement supportées,
* mais avec un suivi d'instructions de structuration plus fragile à
* cette taille dans la pratique et, empiriquement (voir ci-dessus), pas
* plus rapide non plus sur ce benchmark. Gardé comme point de comparaison
* plutôt que retiré : la latence relative entre les deux dépend du
* backend d'inférence (CUDA/Vulkan/CPU pur) et du matériel, un résultat
* obtenu sur une seule machine ne généralise pas forcément.
*
* Les deux sont quantisés en `Q4_K_M` le compromis taille/qualité standard
* pour de l'inférence CPU (~4.5 bits/poids, largement suffisant pour une
* tâche d'extraction structurée, contrairement à de la génération créative
* longue une quantisation plus fine se voit davantage).
*/
export const RECOMMENDED_MODELS: Record<RecommendedModelKey, RecommendedModel> = {
"qwen2.5-1.5b": {
hfUri: "hf:Qwen/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M",
rationale:
"Meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON — et, empiriquement sur ce benchmark, aussi la latence la plus basse malgré la taille plus grande.",
},
"llama-3.2-1b": {
hfUri: "hf:bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M",
rationale:
"Plus petit, structuration JSON moins fiable à 1B, et pas plus rapide qu'un Qwen 1.5B sur ce benchmark — conservé comme point de comparaison, pas comme choix latence.",
},
};
/** Répertoire où les modèles GGUF téléchargés sont mis en cache — à côté de ce fichier, jamais commité (voir `.gitignore` du dossier). */
const MODELS_DIRECTORY = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "models");
/**
* Résout le chemin du fichier GGUF à charger : un chemin local explicite
* via `LLM_TECH_STEP_MODEL_PATH` prime toujours (utile hors-ligne, ou en CI
* un téléchargement réseau à la volée n'est pas souhaitable) ; sinon,
* {@link RECOMMENDED_MODELS} est résolu par `resolveModelFile`, qui
* télécharge le fichier une seule fois dans {@link MODELS_DIRECTORY} puis le
* réutilise tel quel aux exécutions suivantes.
*/
async function resolveModelPath(modelKey: RecommendedModelKey): Promise<string> {
const explicitPath = process.env.LLM_TECH_STEP_MODEL_PATH;
if (explicitPath !== undefined && explicitPath.length > 0) return explicitPath;
return await resolveModelFile(RECOMMENDED_MODELS[modelKey].hfUri, MODELS_DIRECTORY);
}
// ---------------------------------------------------------------------------
// LocalLlmStepAnalyzer — enrobage node-llama-cpp
// ---------------------------------------------------------------------------
/**
* Charge un modèle GGUF local et l'expose comme un service d'analyse
* d'étapes de recette vraie `class` (pas un objet littéral), même
* convention que `TechStepClassifierService` : elle possède un état réel
* (modèle chargé, contexte, grammaire compilée) coûteux à reconstruire,
* jamais recréé par appel.
*/
export class LocalLlmStepAnalyzer {
/** Instance `node-llama-cpp` — porte d'entrée vers le binding natif llama.cpp. `undefined` avant `initialize()`. */
private _llama: Awaited<ReturnType<typeof getLlama>> | undefined;
/** Modèle GGUF chargé en mémoire. `undefined` avant `initialize()`. */
private _model:
| Awaited<ReturnType<Awaited<ReturnType<typeof getLlama>>["loadModel"]>>
| undefined;
/** Contexte d'inférence (fenêtre de contexte + cache KV) dérivé de `_model`. `undefined` avant `initialize()`. */
private _context:
| Awaited<
ReturnType<
Awaited<ReturnType<Awaited<ReturnType<typeof getLlama>>["loadModel"]>>["createContext"]
>
>
| undefined;
/**
* Grammaire GBNF compilée depuis {@link KITCHEN_ACTIONS_JSON_SCHEMA}
* compilée une seule fois, réutilisée à chaque `analyzeStep`. Typée
* explicitement via le type générique `LlamaJsonSchemaGrammar<Schema>`
* (plutôt qu'un `Awaited<ReturnType<...>>` sur la méthode générique
* `createGrammarForJsonSchema`, qui perd le type précis du schéma faute
* d'argument concret à cet endroit) pour que `grammar.parse()` renvoie un
* type déjà aligné sur {@link KitchenActionsGrammarResult}. `undefined`
* avant `initialize()`.
*/
private _grammar: LlamaJsonSchemaGrammar<typeof KITCHEN_ACTIONS_JSON_SCHEMA> | undefined;
/**
* Charge le modèle (téléchargement au besoin, voir {@link resolveModelPath}),
* crée son contexte d'inférence et compile la grammaire JSON la partie
* coûteuse (souvent plusieurs secondes, dominée par le chargement des
* poids depuis disque), à faire une seule fois avant tout `analyzeStep`.
*
* Journalise chaque sous-étape (`console.info`, autorisé par
* `biome.json` voir `suspicious.noConsole`) : cette méthode reste muette
* pendant plusieurs secondes à secondes-longues sans ça (résolution/
* téléchargement du modèle, chargement des poids, création du contexte,
* compilation de la grammaire), et rien ne dit à l'utilisateur laquelle
* de ces sous-étapes est en cours.
*/
public async initialize(modelKey: RecommendedModelKey): Promise<void> {
console.info(`[poc] résolution du modèle "${modelKey}"...`);
const modelPath = await resolveModelPath(modelKey);
console.info(`[poc] modèle : ${modelPath}`);
console.info("[poc] initialisation de node-llama-cpp...");
this._llama = await getLlama();
console.info("[poc] chargement des poids en mémoire...");
this._model = await this._llama.loadModel({ modelPath });
console.info("[poc] création du contexte d'inférence...");
this._context = await this._model.createContext({ contextSize: 4096 });
console.info("[poc] compilation de la grammaire JSON...");
this._grammar = await this._llama.createGrammarForJsonSchema(KITCHEN_ACTIONS_JSON_SCHEMA);
}
/**
* Force un premier appel d'inférence factice, séparément de
* `initialize()` et avant tout appel mesuré par le benchmark même rôle
* que `TechStepClassifierService.warmUp()` côté `node-nlp`
* (`tech-step-matcher.ts`) : le tout premier `session.prompt()` sur un
* contexte fraîchement créé paie un coût caché que `initialize()` ne
* couvre pas (spin-up du pool de threads llama.cpp, allocation du cache
* KV, initialisation paresseuse du tokenizer) mesuré entre 15 et 20+
* secondes selon le modèle/matériel, contre quelques secondes pour les
* appels suivants sur la même phrase. Sans cet appel, c'est la première
* phrase du benchmark qui absorbe ce coût, faussant sa latence
* moyenne/max sans rapport avec le coût réel d'une inférence en régime
* établi.
*/
public async warmUp(): Promise<void> {
await this.analyzeStep("Faites chauffer une poêle.");
}
/**
* Analyse une étape de recette et renvoie sa séquence ordonnée d'actions.
*
* Une séquence `LlamaContextSequence` dédiée est allouée pour CET appel
* puis libérée en sortie (`finally`), plutôt que de réutiliser une session
* de chat partagée : `LlamaChatSession` accumule l'historique de
* conversation à chaque `prompt()`, ce qui aurait fait grandir le contexte
* (et donc la latence mesurée) au fil des phrases du benchmark au lieu de
* mesurer chaque étape dans des conditions comparables. Le contexte par
* défaut n'autorise qu'une seule séquence active à la fois
* (`createContext()` sans `sequences` explicite) d' la libération
* immédiate, indispensable pour que l'appel suivant puisse en allouer une
* nouvelle.
*/
public async analyzeStep(stepText: string): Promise<RecipeStepAnalysis> {
if (this._llama === undefined || this._context === undefined || this._grammar === undefined) {
throw new Error("LocalLlmStepAnalyzer.initialize() must be awaited before analyzeStep().");
}
const context = this._context;
const grammar = this._grammar;
const sequence = context.getSequence();
try {
const session = new LlamaChatSession({
contextSequence: sequence,
systemPrompt: SYSTEM_PROMPT,
});
const response = await session.prompt(stepText, { grammar });
// La grammaire garantit un JSON syntaxiquement conforme au schéma —
// ce cast ne fait que réattacher le type nommé `KitchenActionsGrammarResult`
// (le schéma étant défini structurellement, pas de risque `any`).
const parsed = grammar.parse(response) as KitchenActionsGrammarResult;
return { originalText: stepText, actions: parsed.actions };
} finally {
await sequence.dispose();
}
}
/** Libère le modèle et son contexte — à appeler une fois le benchmark terminé, la mémoire native n'étant pas gérée par le GC de V8. */
public async dispose(): Promise<void> {
await this._context?.dispose();
await this._model?.dispose();
}
}
// ---------------------------------------------------------------------------
// Benchmark
// ---------------------------------------------------------------------------
/** Imprime le détail (action/verbe/ingrédients/durée/température/ustensiles) de chaque échantillon — matière première pour comparer à l'œil avec les autres moteurs. */
function printDetailedResults(samples: readonly BenchmarkSample<RecipeStepAnalysis>[]): void {
for (const sample of samples) {
console.info(
`\n[${sample.sentence.id}] (${sample.sentence.locale}) — ${sample.latencyMs.toFixed(0)} ms`,
);
console.info(` texte : ${sample.sentence.text}`);
console.info(` attendu : ${sample.sentence.note}`);
console.table(
sample.result.actions.map((action) => ({
action: action.action,
verbe: action.verb,
ingrédients: action.ingredients.join(", "),
"durée (min)": action.durationMinutes ?? "—",
température: action.temperature ?? "—",
ustensiles: action.utensils.join(", "),
})),
);
}
}
/**
* Point d'entrée : charge le modèle choisi via `LLM_TECH_STEP_MODEL`
* (`"qwen2.5-1.5b"` par défaut, voir {@link RECOMMENDED_MODELS}), lance le
* benchmark sur les 7 phrases partagées (`shared/test-sentences.ts`),
* imprime les résultats détaillés puis le récapitulatif, et libère le
* modèle avant de quitter.
*/
async function main(): Promise<void> {
const modelKey: RecommendedModelKey =
process.env.LLM_TECH_STEP_MODEL === "llama-3.2-1b" ? "llama-3.2-1b" : "qwen2.5-1.5b";
console.info(
`[poc] modèle sélectionné : ${modelKey} (${RECOMMENDED_MODELS[modelKey].rationale})`,
);
const analyzer = new LocalLlmStepAnalyzer();
const rssBeforeLoad = process.memoryUsage().rss;
// `performance.now()` plutôt que `console.time`/`console.timeEnd` — la
// config Biome du repo n'autorise que error/warn/info/debug/table/assert
// sur `console` (voir `biome.json`, `suspicious.noConsole`), pas `time`.
const loadStartedAt = performance.now();
try {
await analyzer.initialize(modelKey);
} catch (err) {
console.error(
"[poc] échec du chargement du modèle — vérifier LLM_TECH_STEP_MODEL_PATH / la connexion réseau pour le téléchargement initial",
err,
);
process.exitCode = 1;
return;
}
const loadDurationMs = performance.now() - loadStartedAt;
const modelRssMb = (process.memoryUsage().rss - rssBeforeLoad) / (1024 * 1024);
console.info(
`[poc] modèle chargé en ${loadDurationMs.toFixed(0)} ms (+${modelRssMb.toFixed(1)} Mo RSS)`,
);
// Absorbe ici le coût caché du tout premier appel d'inférence (voir
// LocalLlmStepAnalyzer.warmUp) plutôt que de laisser la première phrase
// du benchmark le payer — sans ça, sa latence n'est pas comparable aux
// six autres.
const warmUpStartedAt = performance.now();
try {
await analyzer.warmUp();
} catch (err) {
console.error("[poc] échec du warm-up — le benchmark continue quand même", err);
}
console.info(`[poc] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`);
try {
const benchmarkStartedAt = performance.now();
const samples = await runBenchmark<RecipeStepAnalysis>({
logPrefix: "[poc]",
countOf: (result) => result.actions.length,
countLabel: "action(s) détectée(s)",
analyze: (sentence: BenchmarkSentence) => analyzer.analyzeStep(sentence.text),
});
console.info(
`[poc] benchmark complet en ${(performance.now() - benchmarkStartedAt).toFixed(0)} ms`,
);
printDetailedResults(samples);
printSummaryTable(samples, (result) => result.actions.length, "actions détectées");
} finally {
try {
await analyzer.dispose();
} catch (err) {
console.error("[poc] erreur lors de la libération du modèle", err);
}
}
}
if (isMainModule(import.meta.url)) {
await main();
}

View file

@ -0,0 +1,671 @@
/**
* PoC autonome classifieur `node-nlp` FRAIS, entraîné directement sur la
* taxonomie à 7 catégories de {@link KitchenActionType} (partagée avec
* `llm-tech-step-poc.ts`), plutôt qu'une réutilisation de
* `TechStepClassifierService` (`apps/api/src/lib/recipe-matching/
* tech-step-matcher.ts`, taxonomie fine à ~26 techniques, DB-backed). Deux
* raisons de repartir de zéro plutôt que de réutiliser l'existant :
*
* 1. **Comparaison vraiment terme à terme** : la V1 de ce PoC comparait un
* LLM sortant du `KitchenActionType` (7 catégories) à `node-nlp` sortant
* des `TechStep` (~26 techniques) deux taxonomies différentes rendaient
* le nombre de détections difficile à comparer directement. Entraîné ici
* sur la même taxonomie que le LLM, ses sorties sont directement
* comparables catégorie par catégorie.
* 2. **Un score de confiance EXPLOITABLE par le pipeline hybride**
* (`hybrid-tech-step-poc.ts`) : `TechStepClassifierService` masque son
* score en retombant silencieusement sur l'ancre NER dès qu'il est sous
* son seuil interne (voir sa propre doc, point 3) utile pour son usage
* en prod, mais ça cache exactement le signal dont un pipeline hybride a
* besoin pour décider quand basculer vers le LLM. Ce classifieur-ci
* renvoie toujours le score BRUT du classifieur, jamais masqué.
*
* Même pipeline NER -> découpage en clauses -> classification par clause
* que `tech-step-matcher.ts` (même principe, implémentation propre à ce
* PoC {@link splitIntoClauses} ici est une version simplifiée : split au
* plus proche espace du milieu de l'écart entre deux candidats, sans la
* priorité aux frontières de phrase de la version production). Aucune
* dépendance à `apps/api`/Postgres un `TechStep.uid -> id` n'existe pas
* ici, les catégories `KitchenActionType` sont directement les noms
* d'intention node-nlp, pas de résolution DB nécessaire.
*
* Usage :
*
* ```bash
* cd experiments/llm-tech-step-poc
* pnpm install --ignore-workspace
* pnpm bench:nlp
* ```
*/
import { performance } from "node:perf_hooks";
import { NlpManager } from "node-nlp";
import {
type BenchmarkSample,
printSummaryTable,
runBenchmark,
} from "./shared/benchmark-harness.js";
import { KitchenActionType } from "./shared/kitchen-action.js";
import { isMainModule } from "./shared/module-entry.js";
import type { BenchmarkSentence } from "./shared/test-sentences.js";
// ---------------------------------------------------------------------------
// Corpus d'entraînement — 7 catégories, FR + EN
// ---------------------------------------------------------------------------
/** Vocabulaire d'une catégorie pour une langue — mêmes noms de champs que `tech-step-training-data.ts` (`synonyms` pour la NER, `utterances` pour la classification d'intention), format familier plutôt que réinventé. */
interface CategoryLocaleData {
/** Mots/courtes expressions repérés par la NER (entités enum) — servent d'ancres pour {@link splitIntoClauses}. */
synonyms: string[];
/** Phrases complètes utilisées pour entraîner la classification d'intention — la partie qui porte vraiment le sens, au-delà du mot-clé brut. */
utterances: string[];
}
/** Le vocabulaire complet d'une catégorie de {@link KitchenActionType}, dans les deux langues. */
interface CategoryTrainingData {
action: KitchenActionType;
fr: CategoryLocaleData;
en: CategoryLocaleData;
}
/**
* Corpus volontairement compact (PoC, pas un remplacement du corpus
* production `tech-step-training-data.ts`) mais couvrant les 7 catégories
* dans les deux langues. Les synonymes préfèrent un seul mot distinctif
* ("revenir" plutôt que "faire revenir"/"faites revenir") quand c'est
* possible plutôt qu'une phrase figée : une leçon tirée du run précédent de
* ce PoC, `apps/api`'s "faites revenir" (deux mots) ratait "faites-**les**-
* revenir" le pronom clitique français insère un mot entre les deux et
* casse un matching de phrase contiguë. Un synonyme mono-mot comme
* "revenir" matche quel que soit ce qui le précède.
*/
const TRAINING_DATA: readonly CategoryTrainingData[] = [
{
action: KitchenActionType.CUT,
fr: {
synonyms: [
"émincer",
"émincez",
"éminçez",
"couper",
"coupez",
"hacher",
"hachez",
"trancher",
"tranchez",
"ciseler",
"ciselez",
"éplucher",
"épluchez",
"découper",
"découpez",
],
utterances: [
"émincer finement les oignons",
"couper les légumes en petits dés",
"hacher l'ail très finement avant de l'ajouter",
"éplucher puis trancher les carottes",
],
},
en: {
synonyms: [
"dice",
"diced",
"dicing",
"chop",
"chopped",
"chopping",
"slice",
"sliced",
"slicing",
"mince",
"minced",
"mincing",
"peel",
"peeled",
"peeling",
],
utterances: [
"dice the tomatoes into small cubes",
"chop the onions finely before cooking",
"slice the carrots into thin rounds",
"peel and mince the garlic cloves",
],
},
},
{
action: KitchenActionType.COOK,
fr: {
synonyms: [
"cuire",
"cuisez",
"revenir",
"mijoter",
"mijotez",
"bouillir",
"frire",
"griller",
"grillez",
"rôtir",
"rôtissez",
"fondre",
"chauffer",
"chauffez",
],
utterances: [
"faire revenir les oignons à la poêle avec un peu d'huile",
"laisser mijoter à feu doux pendant vingt minutes",
"faire fondre le beurre jusqu'à ce qu'il disparaisse dans la poêle",
"cuire les pâtes dans une grande casserole d'eau bouillante",
],
},
en: {
synonyms: [
"cook",
"cooked",
"cooking",
"fry",
"fried",
"frying",
"simmer",
"simmered",
"simmering",
"boil",
"boiled",
"boiling",
"grill",
"grilled",
"grilling",
"roast",
"roasted",
"melt",
"melted",
"melting",
"sauté",
"sautéed",
"sear",
"seared",
],
utterances: [
"simmer everything in a saucepan over low heat",
"grill the chicken over medium-high heat",
"melt the butter in a small saucepan",
"cook the pasta in a large pot of boiling water",
],
},
},
{
action: KitchenActionType.MIX,
fr: {
synonyms: [
"mélanger",
"mélangez",
"fouetter",
"fouettez",
"incorporer",
"incorporez",
"remuer",
"remuez",
"battre",
"battez",
],
utterances: [
"mélanger la farine et le sucre dans un saladier",
"fouetter les œufs jusqu'à ce qu'ils blanchissent",
"incorporer délicatement la crème fouettée",
"remuer sans arrêt jusqu'à épaississement",
],
},
en: {
synonyms: [
"mix",
"mixed",
"mixing",
"whisk",
"whisked",
"whisking",
"fold",
"folded",
"folding",
"stir",
"stirred",
"stirring",
"combine",
"combined",
"beat",
"beaten",
"beating",
],
utterances: [
"whisk the eggs and sugar together until pale and fluffy",
"fold in the sifted flour gently",
"stir constantly until the mixture thickens",
"combine all the dry ingredients in a bowl",
],
},
},
{
action: KitchenActionType.REST,
fr: {
synonyms: ["reposer", "reposez", "mariner", "marinez", "refroidir", "refroidissez"],
utterances: [
"laisser reposer la pâte pendant trente minutes",
"laisser mariner la viande toute une nuit au réfrigérateur",
"laisser refroidir avant de découper",
],
},
en: {
synonyms: [
"rest",
"rested",
"resting",
"marinate",
"marinated",
"marinating",
"chill",
"chilled",
"chilling",
"cool",
"cooled",
"cooling",
],
utterances: [
"let the dough rest for thirty minutes",
"marinate the chicken in the fridge overnight",
"let it cool completely before slicing",
"let it rest for a few minutes before serving",
],
},
},
{
action: KitchenActionType.SEASON,
fr: {
synonyms: [
"assaisonner",
"assaisonnez",
"saler",
"salez",
"poivrer",
"poivrez",
"épicer",
"épicez",
],
utterances: [
"assaisonner avec du sel et du poivre",
"saler et poivrer selon le goût",
"épicer généreusement avant de servir",
],
},
en: {
synonyms: [
"season",
"seasoned",
"seasoning",
"salt",
"salted",
"pepper",
"peppered",
"spice",
"spiced",
],
utterances: [
"season with salt and pepper",
"add spices to taste",
"salt and pepper generously before cooking",
],
},
},
{
action: KitchenActionType.PREHEAT,
fr: {
synonyms: ["préchauffer", "préchauffez", "préchauffage"],
utterances: [
"préchauffer le four à cent quatre-vingts degrés",
"préchauffer la poêle avant d'ajouter l'huile",
],
},
en: {
synonyms: ["preheat", "preheated", "preheating"],
utterances: [
"preheat the oven to 350 degrees",
"preheat the pan over medium heat before adding oil",
],
},
},
{
action: KitchenActionType.OTHER,
fr: {
synonyms: [
"réserver",
"réservez",
"égoutter",
"égouttez",
"dresser",
"dressez",
"servir",
"servez",
"transférer",
"transférez",
],
utterances: [
"réserver de côté pendant la préparation du reste",
"égoutter les pâtes en gardant un peu d'eau de cuisson",
"dresser harmonieusement dans l'assiette",
],
},
en: {
synonyms: [
"set aside",
"drain",
"drained",
"draining",
"plate",
"plated",
"plating",
"serve",
"served",
"transfer",
"transferred",
"pat dry",
],
utterances: [
"set it aside for later use",
"drain the pasta reserving some cooking water",
"pat the chicken thighs dry with paper towel",
"transfer everything to a serving dish",
],
},
},
];
// ---------------------------------------------------------------------------
// NER -> découpage en clauses (implémentation propre à ce PoC, simplifiée)
// ---------------------------------------------------------------------------
/** Une mention candidate d'une catégorie, trouvée par NER — le matériau brut dont {@link splitIntoClauses} découpe des clauses. */
interface CategoryCandidate {
action: KitchenActionType;
start: number;
end: number;
}
/** Une clause découpée autour d'un candidat (ou l'unique clause "tout le texte" si aucun candidat n'a été trouvé). */
interface StepClause {
start: number;
end: number;
anchor: CategoryCandidate | null;
}
/**
* Point de coupe entre deux candidats consécutifs l'espace le plus proche
* du milieu de l'écart `[gapStart, gapEnd)`, ou le milieu brut si l'écart ne
* contient aucun espace. Version simplifiée de l'équivalent
* `tech-step-matcher.ts` : pas de priorité aux frontières de phrase, un
* compromis PoC assumé (voir le doc-comment en tête de fichier) un span
* de clause légèrement moins net qu'en production, mais qui ne coupe jamais
* un mot en deux.
*/
function findGapSplitPoint(text: string, gapStart: number, gapEnd: number): number {
if (gapStart >= gapEnd) return gapStart;
const midpoint = Math.floor((gapStart + gapEnd) / 2);
let best: number | null = null;
let bestDistance = Number.POSITIVE_INFINITY;
for (let i = gapStart; i < gapEnd; i++) {
if (!/\s/.test(text[i] ?? "")) continue;
const distance = Math.abs(i - midpoint);
if (distance < bestDistance) {
best = i;
bestDistance = distance;
}
}
return best ?? midpoint;
}
/**
* Découpe `text` en clauses autour de `candidates`, une clause par
* candidat même principe que `tech-step-matcher.ts` : zéro candidat -> tout
* le texte est une clause sans ancre ; un candidat -> tout le texte est une
* clause avec cette ancre ; deux ou plus -> une clause par candidat, coupée
* à {@link findGapSplitPoint} entre chaque paire consécutive.
*/
function splitIntoClauses(text: string, candidates: readonly CategoryCandidate[]): StepClause[] {
if (candidates.length === 0) {
return [{ start: 0, end: text.length, anchor: null }];
}
const sorted = [...candidates].sort((a, b) => a.start - b.start);
const [first, ...rest] = sorted;
if (first === undefined) {
return [{ start: 0, end: text.length, anchor: null }];
}
const clauses: StepClause[] = [];
let clauseStart = 0;
let anchor = first;
for (const next of rest) {
const splitPoint = findGapSplitPoint(text, anchor.end, next.start);
clauses.push({ start: clauseStart, end: splitPoint, anchor });
clauseStart = splitPoint;
anchor = next;
}
clauses.push({ start: clauseStart, end: text.length, anchor });
return clauses;
}
// ---------------------------------------------------------------------------
// NlpTechStepClassifier
// ---------------------------------------------------------------------------
/** Une action détectée dans une clause, avec le score BRUT du classifieur — jamais masqué par un repli silencieux, voir le doc-comment en tête de fichier (point 2). */
export interface NlpActionMatch {
action: KitchenActionType;
/** Score du classifieur `node-nlp` pour cette clause, `[0, 1]` — `0` quand le classifieur n'a rien reconnu du tout (`intent === "None"`) et qu'aucune ancre NER n'existe pour retomber dessus. */
confidence: number;
/** Mot-clé ayant ancré cette clause (le texte de l'ancre NER), ou le texte de la clause entière si aucune ancre n'existe. */
matchedText: string;
/** Texte complet de la clause classifiée. */
clauseText: string;
start: number;
end: number;
contextStart: number;
contextEnd: number;
}
/** Résultat complet de l'analyse d'une étape par le classifieur NLP. */
export interface NlpStepAnalysis {
originalText: string;
matches: NlpActionMatch[];
/**
* Confiance au niveau de l'étape entière le MINIMUM des confidences de
* ses clauses (une étape n'est fiable que si TOUTES ses clauses le sont),
* ou `0` si aucune clause n'a produit de match. C'est ce champ que
* `hybrid-tech-step-poc.ts` compare à son seuil pour décider d'escalader
* vers le LLM.
*/
overallConfidence: number;
}
/** `value` est-elle une des 7 valeurs de {@link KitchenActionType} ? — `node-nlp` renvoie l'intent sous forme de `string` brute, à valider avant de la traiter comme une vraie catégorie. */
function isKitchenActionType(value: string): value is KitchenActionType {
return (Object.values(KitchenActionType) as string[]).includes(value);
}
/**
* Classifieur `node-nlp` frais pour ce PoC vraie `class` (pas un objet
* littéral), même convention que `TechStepClassifierService`/
* `LocalLlmStepAnalyzer` : possède un état réel (modèle entraîné),
* coûteux à reconstruire, jamais recréé par appel.
*/
export class NlpTechStepClassifier {
private readonly _manager: NlpManager;
/** Mémoïse l'entraînement — `undefined` jusqu'au premier appel, chaque appelant (concurrent ou non) attend ensuite la même promesse plutôt que de ré-entraîner. */
private _trained: Promise<void> | undefined;
public constructor() {
this._manager = new NlpManager({
languages: ["fr", "en"],
forceNER: true,
nlu: { log: false },
// Seuil `1` (exact, après normalisation casse/accents/stemming de
// node-nlp) plutôt que le défaut `0.8` (fuzzy/Levenshtein) — même
// raisonnement que `tech-step-matcher.ts` : la précision de la NER
// compte plus que son rappel ici, elle ne fait que proposer des
// candidats de découpage, c'est la classification d'intention qui
// doit vraiment avoir raison.
ner: { threshold: 1 },
// Jamais de persistance sur disque — le corpus en code est la seule
// source de vérité, un modèle stale sur disque masquerait
// silencieusement une mise à jour du corpus.
autoSave: false,
autoLoad: false,
});
}
/** Force l'entraînement plus l'initialisation paresseuse de node-nlp (stemmers/tokenizers par langue, chargés au premier `process()` réel) à se faire maintenant, avant tout appel mesuré par le benchmark. */
public async warmUp(): Promise<void> {
await this.analyzeStep("Faites chauffer une poêle.", "fr");
}
/** Analyse une étape et renvoie ses matches + sa confiance globale. Voir le doc-comment en tête de fichier pour le pipeline NER -> clauses -> classification. */
public async analyzeStep(text: string, locale: "fr" | "en"): Promise<NlpStepAnalysis> {
await this._ensureTrained();
if (text.trim().length === 0) {
return { originalText: text, matches: [], overallConfidence: 0 };
}
const nerResult = await this._manager.process(locale, text);
const candidates: CategoryCandidate[] = nerResult.entities
.filter((entity) => entity.type === "enum" && isKitchenActionType(entity.entity))
.map((entity) => ({
// Le filtre ci-dessus garantit `isKitchenActionType(entity.entity)`
// — cast plutôt que refiltrer, `Array.prototype.filter` n'affine
// pas le type de `entity.entity` (`string`) tout seul.
action: entity.entity as KitchenActionType,
start: entity.start,
// node-nlp's `end` est inclusif — `+1` convertit vers `[start, end)`.
end: entity.end + 1,
}));
const clauses = splitIntoClauses(text, candidates);
const matches: NlpActionMatch[] = [];
for (const clause of clauses) {
const clauseText = text.slice(clause.start, clause.end).trim();
let action: KitchenActionType;
let confidence: number;
if (clauseText.length === 0) {
action = clause.anchor?.action ?? KitchenActionType.OTHER;
confidence = 0;
} else {
const result = await this._manager.process(locale, clauseText);
if (result.intent !== "None" && isKitchenActionType(result.intent)) {
action = result.intent;
confidence = result.score;
} else {
// Contrairement à `TechStepClassifierService`, pas de repli
// silencieux sur un score "rescapé" : le score `0` reflète
// honnêtement qu'aucune classification fiable n'a eu lieu,
// l'ancre NER sert seulement à choisir QUELLE catégorie
// afficher, pas à masquer que la confiance réelle est nulle.
action = clause.anchor?.action ?? KitchenActionType.OTHER;
confidence = 0;
}
}
const matchedText =
clause.anchor !== null ? text.slice(clause.anchor.start, clause.anchor.end) : clauseText;
matches.push({
action,
confidence,
matchedText,
clauseText,
start: clause.anchor?.start ?? clause.start,
end: clause.anchor?.end ?? clause.end,
contextStart: clause.start,
contextEnd: clause.end,
});
}
const overallConfidence =
matches.length === 0 ? 0 : Math.min(...matches.map((match) => match.confidence));
return { originalText: text, matches, overallConfidence };
}
private async _ensureTrained(): Promise<void> {
if (this._trained === undefined) {
this._trained = this._train();
}
try {
await this._trained;
} catch (err) {
// Un entraînement raté doit pouvoir être retenté au prochain appel,
// pas laisser tout appel futur échouer contre la même promesse figée.
this._trained = undefined;
throw err;
}
}
private async _train(): Promise<void> {
for (const entry of TRAINING_DATA) {
for (const [locale, data] of [
["fr", entry.fr],
["en", entry.en],
] as const) {
if (data.synonyms.length > 0) {
this._manager.addNamedEntityText(entry.action, entry.action, [locale], data.synonyms);
}
for (const utterance of data.utterances) {
this._manager.addDocument(locale, utterance, entry.action);
}
}
}
await this._manager.train();
}
}
// ---------------------------------------------------------------------------
// Benchmark
// ---------------------------------------------------------------------------
/** Imprime le détail (action/confiance/mot-clé/clause) de chaque échantillon — matière première pour comparer à l'œil avec le LLM. */
function printDetailedResults(samples: readonly BenchmarkSample<NlpStepAnalysis>[]): void {
for (const sample of samples) {
console.info(
`\n[${sample.sentence.id}] (${sample.sentence.locale}) — ${sample.latencyMs.toFixed(0)} ms, confiance globale ${sample.result.overallConfidence.toFixed(2)}`,
);
console.info(` texte : ${sample.sentence.text}`);
console.info(` attendu : ${sample.sentence.note}`);
console.table(
sample.result.matches.map((match) => ({
action: match.action,
confiance: match.confidence.toFixed(2),
mot_clé: match.matchedText,
clause: match.clauseText,
})),
);
}
}
async function main(): Promise<void> {
const classifier = new NlpTechStepClassifier();
console.info("[nlp] warm-up (entraînement + init paresseuse de node-nlp)...");
const warmUpStartedAt = performance.now();
await classifier.warmUp();
console.info(`[nlp] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`);
const benchmarkStartedAt = performance.now();
const samples = await runBenchmark<NlpStepAnalysis>({
logPrefix: "[nlp]",
countOf: (result) => result.matches.length,
countLabel: "action(s) détectée(s)",
analyze: (sentence: BenchmarkSentence) =>
classifier.analyzeStep(sentence.text, sentence.locale),
});
console.info(
`[nlp] benchmark complet en ${(performance.now() - benchmarkStartedAt).toFixed(0)} ms`,
);
printDetailedResults(samples);
printSummaryTable(samples, (result) => result.matches.length, "actions détectées");
}
if (isMainModule(import.meta.url)) {
await main();
}

View file

@ -0,0 +1,382 @@
/**
* PoC autonome même tâche que `llm-tech-step-poc.ts` (extraction JSON
* contrainte par schéma d'une séquence d'actions culinaires), même
* `SYSTEM_PROMPT` (importé tel quel, voir sa doc), mais via
* [Ollama](https://ollama.com/) au lieu de `node-llama-cpp` — un troisième
* point de comparaison, architecturalement différent des deux autres
* moteurs LLM/NLP de ce PoC plutôt qu'une simple redite :
*
* - **`node-llama-cpp`** charge le binding natif llama.cpp DANS ce process
* Node (mêmes poids, même mémoire, même thread pool que le script).
* - **Ollama** est un serveur HTTP local **séparé** (`ollama serve`, lancé
* par l'app de bureau ou en CLI) — ce script n'est qu'un client HTTP fin
* (`ollama` sur npm, aucune dépendance native, aucun binding à compiler à
* l'installation) qui lui parle en local (`http://127.0.0.1:11434` par
* défaut). Conséquences directes, documentées elles s'appliquent :
* - Pas de téléchargement/cache GGUF géré par ce projet Ollama gère ses
* propres modèles (`~/.ollama/models`), récupérés via `ollama.pull()`
* (voir {@link OllamaStepAnalyzer.initialize}).
* - **Le delta de RSS de ce process ne mesure RIEN d'utile ici** :
* l'inférence tourne dans le process `ollama serve`, pas dans celui-ci
* contrairement à `node-llama-cpp`, le binding natif partage la
* mémoire du process Node. Cette colonne du récapitulatif reste
* affichée (même harness que les deux autres moteurs) mais est à
* ignorer pour ce script, voir `README.md`.
* - Nécessite Ollama installé et **son serveur déjà lancé** en dehors de
* ce script (pas de "just works" comme le binding embarqué)
* {@link OllamaStepAnalyzer.initialize} échoue avec un message explicite
* si le serveur n'est pas joignable plutôt qu'une erreur `fetch` brute.
* - Le schéma JSON imposé au modèle (`format`, voir
* {@link KITCHEN_ACTIONS_JSON_SCHEMA}) accepte du JSON Schema standard
* (`type: ["string", "null"]` pour un champ nullable) plus simple que
* le détour `oneOf: [{type:"null"}, {type:"..."}]` qu'exige la
* grammaire GBNF de node-llama-cpp (voir `llm-tech-step-poc.ts`), un
* autre point de comparaison entre les deux mécanismes de contrainte.
*
* Usage : voir `README.md`. En bref :
*
* ```bash
* ollama serve # dans un terminal séparé, si pas déjà lancé
* cd experiments/llm-tech-step-poc
* pnpm install --ignore-workspace
* pnpm bench:ollama
* ```
*/
import { performance } from "node:perf_hooks";
import { Ollama } from "ollama";
import { SYSTEM_PROMPT } from "./llm-tech-step-poc.js";
import {
type BenchmarkSample,
printSummaryTable,
runBenchmark,
} from "./shared/benchmark-harness.js";
import { KitchenActionType, type RecipeStepAnalysis } from "./shared/kitchen-action.js";
import { isMainModule } from "./shared/module-entry.js";
import type { BenchmarkSentence } from "./shared/test-sentences.js";
// ---------------------------------------------------------------------------
// Schéma JSON — passé tel quel à Ollama via `format`
// ---------------------------------------------------------------------------
/**
* Schéma JSON standard (pas de dialecte GBNF-spécifique) Ollama valide/
* contraint la génération directement contre ce schéma via son paramètre
* `format`. Champ à champ, en miroir strict de `KitchenAction`
* (`shared/kitchen-action.ts`), même remarque que côté `node-llama-cpp` :
* ça n'impose qu'une SYNTAXE JSON valide, jamais la justesse sémantique du
* contenu c'est {@link SYSTEM_PROMPT} qui porte la sémantique.
*/
const KITCHEN_ACTION_JSON_SCHEMA = {
type: "object",
properties: {
action: { type: "string", enum: Object.values(KitchenActionType) },
verb: { type: "string" },
ingredients: { type: "array", items: { type: "string" } },
durationMinutes: { type: ["number", "null"] },
temperature: { type: ["string", "null"] },
utensils: { type: "array", items: { type: "string" } },
},
required: ["action", "verb", "ingredients", "durationMinutes", "temperature", "utensils"],
};
/** Racine du schéma — même choix qu'en `node-llama-cpp` (`{ actions: [...] }` plutôt qu'un tableau nu), `originalText` volontairement absent, voir `llm-tech-step-poc.ts` pour le raisonnement complet. */
const KITCHEN_ACTIONS_JSON_SCHEMA = {
type: "object",
properties: {
actions: { type: "array", items: KITCHEN_ACTION_JSON_SCHEMA },
},
required: ["actions"],
};
/** Forme attendue du JSON renvoyé par Ollama (`response.message.content`, une chaîne à parser) une fois conforme à {@link KITCHEN_ACTIONS_JSON_SCHEMA}. */
interface KitchenActionsSchemaResult {
actions: RecipeStepAnalysis["actions"];
}
// ---------------------------------------------------------------------------
// Modèles recommandés
// ---------------------------------------------------------------------------
/** Mêmes deux familles de modèles que `llm-tech-step-poc.ts` (voir son comparatif) — pour rester comparable, référencées ici par leur tag Ollama plutôt qu'une URI `hf:`. */
export type RecommendedOllamaModelKey = "qwen2.5-1.5b" | "llama-3.2-1b";
interface RecommendedOllamaModel {
/** Tag tel qu'Ollama le résout (`ollama pull <tag>`) — voir https://ollama.com/library. */
tag: string;
rationale: string;
}
const RECOMMENDED_MODELS: Record<RecommendedOllamaModelKey, RecommendedOllamaModel> = {
"qwen2.5-1.5b": {
tag: "qwen2.5:1.5b",
rationale:
"Même choix par défaut que côté node-llama-cpp : meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON.",
},
"llama-3.2-1b": {
tag: "llama3.2:1b",
rationale:
"Alternative plus légère — voir le comparatif détaillé et les résultats empiriques dans le README et dans llm-tech-step-poc.ts.",
},
};
const DEFAULT_OLLAMA_HOST = "http://127.0.0.1:11434";
// ---------------------------------------------------------------------------
// OllamaStepAnalyzer
// ---------------------------------------------------------------------------
/**
* Client Ollama enrobé comme service d'analyse d'étapes de recette vraie
* `class` (pas un objet littéral), même convention que
* `LocalLlmStepAnalyzer`/`NlpTechStepClassifier` : possède un état réel (le
* client HTTP, le tag du modèle sélectionné), même si ici l'état lourd
* (les poids du modèle) vit dans le process `ollama serve` séparé, pas
* dans cette instance.
*/
export class OllamaStepAnalyzer {
/** Nombre de tentatives avant d'abandonner sur une réponse JSON invalide — voir le doc-comment de {@link OllamaStepAnalyzer.analyzeStep}. */
private static readonly _MAX_PARSE_ATTEMPTS = 3;
private readonly _client: Ollama;
private readonly _host: string;
/** Tag du modèle une fois résolu/pull par `initialize()`. `undefined` avant. */
private _modelTag: string | undefined;
public constructor(host: string = DEFAULT_OLLAMA_HOST) {
this._host = host;
this._client = new Ollama({ host });
}
/**
* Vérifie/télécharge le modèle (`ollama pull`, no-op quasi instantané si
* déjà présent localement Ollama compare les manifestes de couches
* avant de retélécharger quoi que ce soit) et journalise la progression
* par palier de statut plutôt que de rester muet le temps du
* téléchargement (potentiellement plusieurs centaines de Mo au premier
* pull d'un modèle).
*
* Échoue avec un message explicite (plutôt que l'erreur `fetch` brute
* remontée par `ollama-js`) si le serveur Ollama n'est pas joignable
* contrairement à `node-llama-cpp`, ce PoC dépend d'un process externe
* que ce script ne lance pas lui-même.
*/
public async initialize(modelKey: RecommendedOllamaModelKey): Promise<void> {
const tag = RECOMMENDED_MODELS[modelKey].tag;
console.info(`[ollama] vérification/pull du modèle "${tag}" sur ${this._host}...`);
try {
await this._ensureModelPulled(tag);
} catch (err) {
throw new Error(
`OllamaStepAnalyzer: impossible de joindre Ollama sur ${this._host} — le serveur est-il lancé (\`ollama serve\`, ou l'app de bureau Ollama) ?`,
{ cause: err },
);
}
this._modelTag = tag;
}
/** Force un premier appel factice — même rôle que le warm-up des deux autres moteurs : le premier vrai appel `chat()` déclenche le chargement des poids en mémoire côté serveur Ollama, un coût cependant nettement moins visible ici qu'avec node-llama-cpp car mutualisé/mis en cache par le serveur entre plusieurs process clients. */
public async warmUp(): Promise<void> {
await this.analyzeStep("Faites chauffer une poêle.");
}
/**
* Analyse une étape de recette et renvoie sa séquence ordonnée d'actions,
* via `ollama.chat()` contraint par {@link KITCHEN_ACTIONS_JSON_SCHEMA}.
*
* Réessaie jusqu'à {@link _MAX_PARSE_ATTEMPTS} fois si la réponse ne
* parse pas en JSON valide un échec bien réel et reproduit en
* pratique, surtout sur les petits modèles (`qwen2.5:0.5b`,
* `smollm2:360m`...) face aux phrases longues de ce PoC
* (`fr-concat-volumetrie`, `fr-recette-complete`...) : le modèle part en
* boucle de répétition dans le tableau `actions` et n'atteint jamais
* l'accolade fermante avant la limite de tokens
* (`response.done_reason === "length"`, contenu de plusieurs dizaines de
* milliers de caractères observé en pratique). La grammaire imposée par
* `format` contraint la SYNTAXE token par token, elle ne borne pas la
* LONGUEUR du tableau rien ne l'empêche de continuer à générer des
* éléments indéfiniment.
*
* `repeat_penalty`/`num_predict` réduisent nettement l'ampleur du
* dérapage (÷18 observé en pratique sur le pire cas) sans l'éliminer à
* coup sûr sur un modèle assez faible d' le retry, avec une
* température légèrement relevée à partir de la 2e tentative : à
* température 0 stricte, retenter avec des paramètres identiques peut
* reproduire l'échec (déterminisme), une température non nulle donne une
* vraie chance de sortir de la boucle.
*/
public async analyzeStep(stepText: string): Promise<RecipeStepAnalysis> {
if (this._modelTag === undefined) {
throw new Error("OllamaStepAnalyzer.initialize() must be awaited before analyzeStep().");
}
let lastParseError: unknown;
let lastRawContent = "";
for (let attempt = 1; attempt <= OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS; attempt++) {
const response = await this._client.chat({
model: this._modelTag,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: stepText },
],
format: KITCHEN_ACTIONS_JSON_SCHEMA,
options: {
// Température 0 sur la 1re tentative — génération déterministe,
// cohérent avec l'usage d'un schéma imposé : on veut la sortie la
// plus prévisible possible pour ce qui reste discrétionnaire (le
// contenu, pas la syntaxe). Relevée légèrement sur les tentatives
// suivantes uniquement, voir le doc-comment ci-dessus.
temperature: attempt === 1 ? 0 : 0.3,
// Décourage la boucle de répétition qui cause l'essentiel des
// échecs de parsing observés (voir doc-comment) — 1.3 plutôt que
// le défaut ~1.1 d'Ollama, choisi empiriquement contre le pire
// cas reproduit (phrase longue + petit modèle).
repeat_penalty: 1.3,
// Borne le dégât en cas de dérapage malgré repeat_penalty
// (arrête la génération avant plusieurs dizaines de milliers de
// caractères inutiles) sans pénaliser les cas normaux — même la
// phrase la plus longue de ce PoC (recette concaténée, jusqu'à
// une quinzaine d'actions) tient largement dans cette limite une
// fois correctement formée.
num_predict: 2048,
},
stream: false,
});
try {
const parsed = JSON.parse(response.message.content) as KitchenActionsSchemaResult;
return { originalText: stepText, actions: parsed.actions };
} catch (err) {
lastParseError = err;
lastRawContent = response.message.content;
if (attempt < OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS) {
console.error(
`[ollama] réponse JSON invalide (tentative ${attempt}/${OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS}, ${response.message.content.length} caractères, done_reason="${response.done_reason}") — nouvelle tentative...`,
);
}
}
}
throw new Error(
`OllamaStepAnalyzer: réponse JSON invalide malgré le schéma imposé, après ${OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS} tentatives — dernière réponse (${lastRawContent.length} caractères) : "${lastRawContent.slice(0, 500)}${lastRawContent.length > 500 ? "..." : ""}"`,
{ cause: lastParseError },
);
}
/**
* Décharge le modèle de la mémoire du serveur Ollama (`keep_alive: 0`)
* best effort, purement pour ne pas laisser le modèle chargé
* indéfiniment après ce benchmark : `ollama serve` tourne indépendamment
* de ce script (pas lancé ni arrêté par lui), donc rien d'autre à
* libérer côté process Node.
*/
public async dispose(): Promise<void> {
if (this._modelTag === undefined) return;
try {
await this._client.chat({ model: this._modelTag, messages: [], keep_alive: 0 });
} catch (err) {
console.error("[ollama] échec du déchargement du modèle (non bloquant)", err);
}
}
/** Lance un `pull` en streaming et journalise chaque changement de statut (`pulling manifest`, `downloading`, `verifying sha256 digest`...) avec le pourcentage quand Ollama le fournit. */
private async _ensureModelPulled(tag: string): Promise<void> {
const progress = await this._client.pull({ model: tag, stream: true });
let lastStatus = "";
for await (const part of progress) {
if (part.status === lastStatus) continue;
lastStatus = part.status;
const percent =
part.completed !== undefined && part.total !== undefined && part.total > 0
? ` (${Math.round((part.completed / part.total) * 100)}%)`
: "";
console.info(`[ollama] ${part.status}${percent}`);
}
}
}
// ---------------------------------------------------------------------------
// Benchmark
// ---------------------------------------------------------------------------
/** Imprime le détail de chaque échantillon — même format que `llm-tech-step-poc.ts`, pour comparer les deux moteurs LLM à l'œil ligne à ligne. */
function printDetailedResults(samples: readonly BenchmarkSample<RecipeStepAnalysis>[]): void {
for (const sample of samples) {
console.info(
`\n[${sample.sentence.id}] (${sample.sentence.locale}) — ${sample.latencyMs.toFixed(0)} ms`,
);
console.info(` texte : ${sample.sentence.text}`);
console.info(` attendu : ${sample.sentence.note}`);
console.table(
sample.result.actions.map((action) => ({
action: action.action,
verbe: action.verb,
ingrédients: action.ingredients.join(", "),
"durée (min)": action.durationMinutes ?? "—",
température: action.temperature ?? "—",
ustensiles: action.utensils.join(", "),
})),
);
}
}
/**
* Point d'entrée : charge le modèle choisi via `OLLAMA_TECH_STEP_MODEL`
* (`"qwen2.5-1.5b"` par défaut), contre le serveur Ollama de
* `OLLAMA_TECH_STEP_HOST` (`http://127.0.0.1:11434` par défaut), lance le
* benchmark sur les 11 phrases partagées, imprime les résultats détaillés
* puis le récapitulatif, et décharge le modèle avant de quitter.
*/
async function main(): Promise<void> {
const modelKey: RecommendedOllamaModelKey =
process.env.OLLAMA_TECH_STEP_MODEL === "llama-3.2-1b" ? "llama-3.2-1b" : "qwen2.5-1.5b";
const host = process.env.OLLAMA_TECH_STEP_HOST ?? DEFAULT_OLLAMA_HOST;
console.info(
`[ollama] modèle sélectionné : ${modelKey} (${RECOMMENDED_MODELS[modelKey].rationale})`,
);
const analyzer = new OllamaStepAnalyzer(host);
try {
await analyzer.initialize(modelKey);
} catch (err) {
console.error("[ollama] échec de l'initialisation", err);
process.exitCode = 1;
return;
}
const warmUpStartedAt = performance.now();
try {
await analyzer.warmUp();
} catch (err) {
console.error("[ollama] échec du warm-up — le benchmark continue quand même", err);
}
console.info(`[ollama] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`);
try {
const benchmarkStartedAt = performance.now();
const samples = await runBenchmark<RecipeStepAnalysis>({
logPrefix: "[ollama]",
countOf: (result) => result.actions.length,
countLabel: "action(s) détectée(s)",
analyze: (sentence: BenchmarkSentence) => analyzer.analyzeStep(sentence.text),
});
console.info(
`[ollama] benchmark complet en ${(performance.now() - benchmarkStartedAt).toFixed(0)} ms`,
);
printDetailedResults(samples);
console.info(
"\n[ollama] rappel : la colonne 'RSS moy.' ci-dessous ne mesure rien d'utile pour ce moteur — l'inférence tourne dans le process `ollama serve`, pas dans ce script (voir le doc-comment en tête de fichier).",
);
printSummaryTable(samples, (result) => result.actions.length, "actions détectées");
} finally {
try {
await analyzer.dispose();
} catch (err) {
console.error("[ollama] erreur lors de la libération du modèle", err);
}
}
}
if (isMainModule(import.meta.url)) {
await main();
}

View file

@ -0,0 +1,138 @@
/**
* Harness de benchmark partagé par les trois moteurs de ce PoC logs
* itératifs par répétition, mesure latence/RSS, tableau récapitulatif.
* Générique sur `TResult` (la forme de sortie de chaque moteur diffère :
* `RecipeStepAnalysis` pour le LLM, `NlpStepAnalysis` pour le classifieur
* NLP, `HybridStepAnalysis` pour le pipeline hybride) pour que les trois
* scripts réutilisent exactement le même code de mesure/affichage plutôt
* que de le tripler.
*/
import { performance } from "node:perf_hooks";
import type { BenchmarkSentence } from "./test-sentences.js";
import { TEST_SENTENCES } from "./test-sentences.js";
/** Nombre de répétitions mesurées par phrase — même valeur pour les trois moteurs, pour des runs comparables. */
export const REPETITIONS_PER_SENTENCE = 3;
/** Une mesure individuelle (une répétition, une phrase) — la matière première des tableaux récapitulatifs. */
export interface BenchmarkSample<TResult> {
sentence: BenchmarkSentence;
latencyMs: number;
/** Delta de RSS du process Node entre juste avant et juste après cet appel — une approximation de la RAM réellement consommée : `process.memoryUsage()` ne voit que le tas V8, mais un binding natif (llama.cpp) alloue dans le même process, donc le RSS (mémoire résidente totale du process) le capture, au bruit du GC près. */
rssDeltaBytes: number;
result: TResult;
}
/** Formate un delta de RSS en Mo avec un signe explicite (`+`/`-`), pour l'affichage. */
export function formatRssDelta(rssDeltaBytes: number): string {
const megabytes = rssDeltaBytes / (1024 * 1024);
return `${megabytes >= 0 ? "+" : ""}${megabytes.toFixed(1)} Mo`;
}
/** Paramètres de {@link runBenchmark} — un par moteur (LLM/NLP/hybride), voir chaque appelant. */
export interface BenchmarkRunOptions<TResult> {
/** Préfixe des logs itératifs, ex. `"[poc]"`, `"[nlp]"`, `"[hybrid]"`. */
logPrefix: string;
/** Nombre d'éléments détectés dans un résultat — alimente le log par répétition et la colonne de comptage du récapitulatif. */
countOf: (result: TResult) => number;
/** Libellé de ce qui est compté, ex. `"action(s) détectée(s)"` ou `"technique(s) détectée(s)"`. */
countLabel: string;
/** Lance une analyse pour une phrase donnée. Une erreur est journalisée et n'interrompt pas les répétitions suivantes — un run qui plante entièrement à la première réponse mal formée serait bien moins utile qu'un rapport partiel. */
analyze: (sentence: BenchmarkSentence) => Promise<TResult>;
}
/**
* Exécute {@link REPETITIONS_PER_SENTENCE} analyses par phrase de
* {@link TEST_SENTENCES} et renvoie toutes les mesures individuelles,
* journalisant chaque répétition au fur et à mesure (avant ET après)
* plutôt que de rester muet jusqu'au récapitulatif final : un run complet
* peut prendre plusieurs minutes, et savoir on en est quelle phrase,
* quelle répétition, le résultat qui vient de tomber vaut largement le
* bruit de sortie supplémentaire pour ces scripts de benchmark
* (contrairement au code applicatif, `console` est réservé à
* `LoggerService` n'existe pas ici, PoC autonome sans app autour).
*/
export async function runBenchmark<TResult>(
options: BenchmarkRunOptions<TResult>,
): Promise<BenchmarkSample<TResult>[]> {
const { logPrefix, countOf, countLabel, analyze } = options;
const samples: BenchmarkSample<TResult>[] = [];
const totalRuns = TEST_SENTENCES.length * REPETITIONS_PER_SENTENCE;
let runIndex = 0;
for (const [sentenceIndex, sentence] of TEST_SENTENCES.entries()) {
for (let repetition = 1; repetition <= REPETITIONS_PER_SENTENCE; repetition++) {
runIndex++;
console.info(
`${logPrefix} (${runIndex}/${totalRuns}) phrase ${sentenceIndex + 1}/${TEST_SENTENCES.length} "${sentence.id}" (${sentence.locale}) — répétition ${repetition}/${REPETITIONS_PER_SENTENCE}...`,
);
const rssBefore = process.memoryUsage().rss;
const startedAt = performance.now();
try {
const result = await analyze(sentence);
const latencyMs = performance.now() - startedAt;
const rssDeltaBytes = process.memoryUsage().rss - rssBefore;
samples.push({ sentence, latencyMs, rssDeltaBytes, result });
console.info(
`${logPrefix} -> ${latencyMs.toFixed(0)} ms, ${countOf(result)} ${countLabel}, RSS ${formatRssDelta(rssDeltaBytes)}`,
);
} catch (err) {
console.error(
`${logPrefix} -> échec sur "${sentence.id}" (répétition ${repetition})`,
err,
);
}
}
}
return samples;
}
/** Une colonne supplémentaire du tableau récapitulatif, au-delà des colonnes communes — ex. la colonne "moteur" du pipeline hybride. */
export interface SummaryExtraColumn<TResult> {
label: string;
/** Calculée à partir de la DERNIÈRE répétition de la phrase — même logique que la colonne de comptage commune, voir {@link printSummaryTable}. */
valueOf: (lastSample: BenchmarkSample<TResult>) => string | number;
}
/**
* Agrège des {@link BenchmarkSample}s par phrase et imprime le tableau
* récapitulatif du benchmark (latence moyenne/min/max, delta RSS moyen,
* nombre d'éléments détectés, plus toute colonne additionnelle spécifique
* au moteur). Le compte d'éléments détectés est pris sur la DERNIÈRE
* répétition plutôt que moyenné : un nombre d'actions n'a pas de moyenne
* sensée (une info qualitative, pas une mesure continue) la dernière
* répétition sert d'échantillon représentatif, comme dans les runs
* précédents de ce PoC.
*/
export function printSummaryTable<TResult>(
samples: readonly BenchmarkSample<TResult>[],
countOf: (result: TResult) => number,
countColumnLabel: string,
extraColumns: readonly SummaryExtraColumn<TResult>[] = [],
): void {
const rows = TEST_SENTENCES.map((sentence) => {
const sentenceSamples = samples.filter((sample) => sample.sentence.id === sentence.id);
const latencies = sentenceSamples.map((sample) => sample.latencyMs);
const avgLatency = latencies.reduce((sum, value) => sum + value, 0) / (latencies.length || 1);
const avgRssMb =
sentenceSamples.reduce((sum, sample) => sum + sample.rssDeltaBytes, 0) /
(sentenceSamples.length || 1) /
(1024 * 1024);
const lastSample = sentenceSamples.at(-1);
const row: Record<string, string | number> = {
phrase: sentence.id,
langue: sentence.locale,
"runs OK": sentenceSamples.length,
"latence moy. (ms)": latencies.length > 0 ? avgLatency.toFixed(0) : "—",
"latence min (ms)": latencies.length > 0 ? Math.min(...latencies).toFixed(0) : "—",
"latence max (ms)": latencies.length > 0 ? Math.max(...latencies).toFixed(0) : "—",
"RSS moy. (Mo)": sentenceSamples.length > 0 ? avgRssMb.toFixed(1) : "—",
[countColumnLabel]: lastSample !== undefined ? countOf(lastSample.result) : 0,
};
for (const column of extraColumns) {
row[column.label] = lastSample !== undefined ? column.valueOf(lastSample) : "—";
}
return row;
});
console.info("\n=== Récapitulatif ===");
console.table(rows);
}

View file

@ -0,0 +1,65 @@
/**
* Types métier partagés par les trois moteurs de ce PoC
* (`llm-tech-step-poc.ts`, `nlp-tech-step-poc.ts`, `hybrid-tech-step-poc.ts`)
* une seule taxonomie/forme de sortie pour que leurs résultats restent
* comparables terme à terme, plutôt que chaque moteur inventant la sienne.
*/
/**
* Taxonomie fermée des actions culinaires que chaque moteur classe.
* Volontairement large (`OTHER` en filet de sécurité) plutôt qu'exhaustive
* comme les ~25 `TechStep` de `apps/api` : ce PoC teste la *structuration*
* d'une étape en séquence d'actions typées, pas un remplacement à
* iso-vocabulaire du catalogue `TechStep` existant.
*/
export enum KitchenActionType {
/** Travail au couteau — émincer, couper en dés, hacher, éplucher, trancher. */
CUT = "CUT",
/** Cuisson à proprement parler — faire revenir, mijoter, bouillir, cuire au four, griller, fondre. */
COOK = "COOK",
/** Combiner/mélanger des ingrédients entre eux, sans cuisson — mélanger, fouetter, incorporer. */
MIX = "MIX",
/** Laisser reposer/refroidir/mariner/lever, sans intervention active. */
REST = "REST",
/** Assaisonner — sel, poivre, épices, herbes, condiments. */
SEASON = "SEASON",
/** Préchauffage d'un four, d'une poêle ou d'un appareil avant utilisation. */
PREHEAT = "PREHEAT",
/** Toute action ne rentrant dans aucune des catégories ci-dessus (dresser, égoutter, réserver, transférer...). */
OTHER = "OTHER",
}
/**
* Une action atomique extraite d'une étape de recette. Le LLM
* (`llm-tech-step-poc.ts`) remplit tous les champs en une passe ; le
* classifieur NLP (`nlp-tech-step-poc.ts`) ne peut structurellement fournir
* qu'`action`/`verb` (voir sa propre doc) `ingredients`/`utensils` restent
* `[]` et `durationMinutes`/`temperature` restent `null` dans ce cas, jamais
* inventés.
*/
export interface KitchenAction {
/** Catégorie de l'action, parmi {@link KitchenActionType}. */
action: KitchenActionType;
/** Verbe/mot-clé littéral repéré dans le texte (langue d'origine, non traduit) — ex. "émincez", "dice". */
verb: string;
/** Ingrédients sur lesquels porte spécifiquement cette action ; tableau vide si aucun n'est nommé ou non extrait par ce moteur. */
ingredients: string[];
/** Durée en minutes si l'étape en mentionne une (heures/secondes converties) ; `null` sinon ou non extrait par ce moteur. */
durationMinutes: number | null;
/** Mention littérale de température/intensité de feu (ex. "180°C", "feu doux", "medium heat") ; `null` sinon ou non extrait par ce moteur. */
temperature: string | null;
/** Ustensiles/équipements nommés pour cette action ; tableau vide si aucun n'est nommé ou non extrait par ce moteur. */
utensils: string[];
}
/**
* Résultat complet de l'analyse d'une étape la séquence ORDONNÉE
* d'actions qu'elle décrit, alignée sur le texte source pour traçabilité
* dans les résultats du benchmark.
*/
export interface RecipeStepAnalysis {
/** Texte source de l'étape, tel que passé à `analyzeStep`. */
originalText: string;
/** Séquence ordonnée d'actions détectées ; vide si l'étape n'en décrit aucune. */
actions: KitchenAction[];
}

View file

@ -0,0 +1,16 @@
import { pathToFileURL } from "node:url";
/**
* `true` quand le module appelant est le point d'entrée du process (lancé
* directement via `tsx fichier.ts`), `false` quand il est seulement importé
* pour l'une de ses exports `hybrid-tech-step-poc.ts` importe
* `NlpTechStepClassifier` depuis `nlp-tech-step-poc.ts` et
* `LocalLlmStepAnalyzer` depuis `llm-tech-step-poc.ts`. Chacun de ces trois
* scripts lance son propre benchmark via `await main()` en toute fin de
* fichier ; sans cette garde, importer un module pour sa seule classe
* exportée déclencherait aussi SON benchmark complet (téléchargement de
* modèle compris) comme effet de bord de l'import jamais voulu.
*/
export function isMainModule(moduleUrl: string): boolean {
return process.argv[1] !== undefined && moduleUrl === pathToFileURL(process.argv[1]).href;
}

View file

@ -0,0 +1,289 @@
/**
* Les 11 phrases de test partagées par les trois moteurs de ce PoC (7
* phrases de complexité variable + 2 concaténations synthétiques + 2
* concaténations d'une vraie recette, voir {@link TEST_SENTENCES}) un seul
* jeu de phrases, importé par `llm-tech-step-poc.ts`, `nlp-tech-step-poc.ts`
* et `hybrid-tech-step-poc.ts`, pour que leurs runs soient directement
* comparables phrase par phrase sans risque de désynchronisation (un défaut
* de la toute première version de ce PoC, le pendant `node-nlp` vivait
* dans `apps/api` et recopiait ces phrases à la main).
*/
/** Une phrase de test, avec sa langue et ce qui la rend "complexe" (documentation, non exploité par le code). */
export interface BenchmarkSentence {
id: string;
locale: "fr" | "en";
text: string;
/** Ce qui rend cette phrase intéressante à tester — affiché dans les résultats de chaque moteur pour donner du contexte à la comparaison. */
note: string;
}
/**
* Sept phrases complexes, FR et EN, choisies pour couvrir des difficultés
* différentes les trois premières sont la base initiale du PoC, les
* quatre suivantes poussent volontairement plus loin (simultanéité,
* conditions, négations, ambiguïté sémantique d'un même champ) pour
* chercher le point de rupture de chaque moteur, pas juste confirmer qu'il
* gère le cas courant. Deux entrées supplémentaires ({@link TEST_SENTENCES},
* items 8 et 9) concatènent ensuite toutes les phrases d'une même locale en
* un seul "step" géant, pour isoler l'effet du seul VOLUME de texte sur la
* durée de traitement les 7 phrases ci-dessous font varier la
* *complexité* à taille à peu près constante, elles ne disent rien sur
* comment chaque moteur se comporte face à un step simplement plus long
* (plus de tokens à faire générer/parcourir au LLM, plus de clauses à
* découper et classifier pour le NLP) :
*
* 1. FR, plusieurs actions explicites enchaînées avec une durée et un
* ingrédient qui change de forme grammaticale ("les" reprend "oignons").
* 2. EN, même complexité multi-actions, pour comparer directement au 1. sur
* une structure de phrase équivalente dans l'autre langue.
* 3. FR, une phrase-piège sans verbe de technique littéral : aucune action
* n'est nommée explicitement, seul le sens implique une cuisson
* (`COOK`, fonte du beurre) le test le plus direct de "précision
* sémantique, pas seulement mot-clé".
* 4. FR, deux techniques qui se déroulent EN PARALLÈLE ("pendant que...")
* plutôt qu'en séquence un pipeline qui suppose un ordre strictement
* chronologique peut mal restituer que les deux actions se chevauchent
* dans le temps plutôt que de se succéder.
* 5. EN, une action CONDITIONNELLE ("if the batter looks too thick, add a
* splash of milk") noyée entre des actions fermes, plus une fin de
* cuisson exprimée comme un test de résultat ("until a toothpick comes
* out clean") et non comme une durée fixe deux formes d'incertitude
* qu'un extracteur naïf a tendance à aplatir en une action normale.
* 6. FR, très technique (crème pâtissière) : une action MIX et une action
* COOK simultanées ("tout en fouettant" pendant qu'on verse le lait
* chaud), une NÉGATION explicite d'action ("sans jamais laisser
* bouillir" l'inverse d'une action à ne pas enregistrer comme une
* vraie étape), et une fin de cuisson par état ("jusqu'à épaississement")
* plutôt que par durée.
* 7. EN, deux occurrences de `REST` au sens différent (mariner au
* réfrigérateur vs. laisser revenir à température ambiante avant
* cuisson) dans la même phrase, une durée "par face" (6-7 minutes per
* side, pas la durée totale), et un champ température qui désigne un
* SEUIL DE CUISSON à cœur (165°F) plutôt qu'un réglage de feu.
* 8. FR, la concaténation des 4 phrases FR ci-dessus (1, 3, 4, 6) en un
* seul step même contenu, ~4x le volume de texte d'une phrase FR
* normale de ce jeu.
* 9. EN, la concaténation des 3 phrases EN ci-dessus (2, 5, 7) en un seul
* step même principe côté EN.
* 10. FR, les 7 étapes de {@link TEST_RECIPE} ("Tarte aux pommes rustique")
* concaténées en un seul step même principe volumétrique que les
* items 8/9, mais sur du texte de recette RÉEL (rédigé normalement,
* sans les tournures adversariales des phrases 1-7) plutôt qu'une
* concaténation de phrases-pièges synthétiques.
* 11. EN, les 7 étapes de {@link TEST_RECIPE} ("Rustic Apple Tart")
* concaténées en un seul step même principe que l'item 10, côté EN.
*/
const BASE_SENTENCES: readonly BenchmarkSentence[] = [
{
id: "fr-multi-action",
locale: "fr",
text: "Émincez finement les oignons puis faites-les revenir 10 minutes à feu moyen dans une poêle avec un filet d'huile d'olive, puis réservez.",
note: "3 actions enchaînées (CUT, COOK, OTHER), durée + feu + ustensile explicites.",
},
{
id: "en-multi-action",
locale: "en",
text: "Dice the tomatoes, season with salt and pepper, then simmer everything in a saucepan over low heat for about 15 minutes before letting it rest for 5 minutes.",
note: "4 actions enchaînées (CUT, SEASON, COOK, REST), deux durées distinctes à ne pas fusionner.",
},
{
id: "fr-action-implicite",
locale: "fr",
text: "Dans une poêle chaude, faites chauffer une noix de beurre jusqu'à ce qu'il ait disparu, puis ajoutez les échalotes ciselées.",
note: "Cas piège : aucun verbe de cuisson littéral, seul le sens implique COOK (fonte du beurre).",
},
{
id: "fr-actions-paralleles",
locale: "fr",
text: "Pendant que les pâtes cuisent 8 à 10 minutes dans une grande casserole d'eau bouillante salée, faites revenir l'ail et les champignons émincés à la poêle avec un peu de beurre jusqu'à ce qu'ils soient dorés, puis égouttez les pâtes en réservant un peu d'eau de cuisson avant de tout mélanger ensemble hors du feu.",
note: "Deux COOK simultanés (pas séquentiels) + OTHER (égoutter/réserver) + MIX final 'hors du feu' — teste la simultanéité, pas juste l'enchaînement.",
},
{
id: "en-action-conditionnelle",
locale: "en",
text: "Whisk the eggs and sugar together until pale and fluffy, then gradually fold in the sifted flour; if the batter looks too thick, add a splash of milk, and bake at 350°F (175°C) for 25 to 30 minutes, or until a toothpick inserted in the center comes out clean.",
note: "Action conditionnelle ('if...') au milieu d'actions fermes + fin de cuisson par test de résultat plutôt que par durée fixe.",
},
{
id: "fr-simultaneite-et-negation",
locale: "fr",
text: "Faites chauffer le lait avec la gousse de vanille fendue en deux jusqu'à frémissement, puis versez-le progressivement sur le mélange jaunes d'œufs-sucre-maïzena tout en fouettant énergiquement, avant de reverser le tout dans la casserole et de cuire à feu doux en remuant sans arrêt jusqu'à épaississement, sans jamais laisser bouillir.",
note: "MIX+COOK simultanés ('tout en fouettant'), négation explicite d'action ('sans jamais laisser bouillir') et fin de cuisson par état, pas par durée.",
},
{
id: "en-double-rest-et-seuil-cuisson",
locale: "en",
text: "Marinate the chicken thighs in the yogurt mixture for at least 2 hours (overnight if possible), then remove them from the fridge 20 minutes before cooking, pat them dry, and grill over medium-high heat for 6-7 minutes per side until the internal temperature reaches 165°F, letting it rest for 5 minutes before slicing.",
note: "Deux REST de sens différent (marinade vs. retour à température ambiante) + durée 'par face' + température = seuil de cuisson à cœur, pas un réglage de feu.",
},
];
/**
* Une vraie recette (titre, ingrédients, 7 étapes rédigées normalement en
* FR/EN) matière première d'une volumétrie plus RÉALISTE que la
* concaténation de phrases-pièges synthétiques ci-dessus (items 8/9) : du
* texte de recette tel qu'un utilisateur l'écrirait vraiment, sans
* tournures adversariales délibérées. Seuls `steps[].text.fr`/`.en` sont
* utilisés par ce fichier ({@link concatenateRecipeByLocale}) le reste
* (`ingredients`, `servings`, temps de préparation...) est conservé tel
* quel pour une éventuelle recette de test plus complète plus tard, pas
* exploité aujourd'hui.
*/
export const TEST_RECIPE = {
recipe_id: "apple_tart_001",
title: {
fr: "Tarte aux pommes rustique",
en: "Rustic Apple Tart",
},
servings: 6,
prep_time_minutes: 20,
cook_time_minutes: 35,
ingredients: [
{
id: "ing_1",
name: { fr: "pâte brisée", en: "shortcrust pastry" },
quantity: 1,
unit: "piece",
},
{
id: "ing_2",
name: { fr: "pommes Golden", en: "Golden Delicious apples" },
quantity: 4,
unit: "pieces",
},
{
id: "ing_3",
name: { fr: "beurre", en: "butter" },
quantity: 30,
unit: "g",
},
{
id: "ing_4",
name: { fr: "sucre vanillé", en: "vanilla sugar" },
quantity: 1,
unit: "packet",
},
{
id: "ing_5",
name: { fr: "compote de pommes", en: "applesauce" },
quantity: 150,
unit: "g",
},
],
steps: [
{
step_number: 1,
text: {
fr: "Préchauffez votre four à 180°C pendant 10 minutes.",
en: "Preheat your oven to 180°C (350°F) for 10 minutes.",
},
},
{
step_number: 2,
text: {
fr: "Épluchez et évidez les pommes, puis coupez-les en fines lamelles régulières sur votre planche à découper.",
en: "Peel and core the apples, then slice them into thin, even slices on your cutting board.",
},
},
{
step_number: 3,
text: {
fr: "Déroulez la pâte brisée dans un moule à tarte et piquez le fond avec une fourchette.",
en: "Unroll the shortcrust pastry into a tart pan and prick the bottom with a fork.",
},
},
{
step_number: 4,
text: {
fr: "Étalez la compote de pommes de manière égale sur le fond de pâte avec une spatule.",
en: "Spread the applesauce evenly over the pastry base using a spatula.",
},
},
{
step_number: 5,
text: {
fr: "Disposez les lamelles de pommes en rosette par-dessus la compote, puis parsemez de noisettes de beurre et de sucre vanillé.",
en: "Arrange the apple slices in a rosette pattern on top of the sauce, then dot with small knobs of butter and sprinkle with vanilla sugar.",
},
},
{
step_number: 6,
text: {
fr: "Enfournez à 180°C et laissez cuire pendant 35 minutes jusqu'à ce que les bordures soient bien dorées.",
en: "Bake at 180°C (350°F) for 35 minutes until the edges are golden brown.",
},
},
{
step_number: 7,
text: {
fr: "Sortez la tarte du four et laissez-la reposer au frais pendant 15 minutes avant de démouler et de servir.",
en: "Remove the tart from the oven and let it rest at room temperature for 15 minutes before unmolding and serving.",
},
},
],
};
/**
* Concatène les textes de {@link BASE_SENTENCES} d'une locale donnée,
* séparés par un espace, dans leur ordre d'apparition calculé plutôt que
* recopié à la main pour ne jamais désynchroniser le step géant du contenu
* réel des 7 phrases de base (si l'une d'elles change de texte, la
* concaténation suit automatiquement).
*/
function concatenateByLocale(locale: BenchmarkSentence["locale"]): string {
return BASE_SENTENCES.filter((sentence) => sentence.locale === locale)
.map((sentence) => sentence.text)
.join(" ");
}
/**
* Concatène les 7 étapes de {@link TEST_RECIPE} pour une locale donnée,
* dans leur ordre (`step_number`), séparées par un espace même principe
* que {@link concatenateByLocale} mais sur la recette réelle plutôt que sur
* les phrases de test synthétiques.
*/
function concatenateRecipeByLocale(locale: BenchmarkSentence["locale"]): string {
return TEST_RECIPE.steps.map((step) => step.text[locale]).join(" ");
}
const FR_SENTENCE_COUNT = BASE_SENTENCES.filter((sentence) => sentence.locale === "fr").length;
const EN_SENTENCE_COUNT = BASE_SENTENCES.filter((sentence) => sentence.locale === "en").length;
/**
* {@link BASE_SENTENCES} (7 phrases, complexité variable à taille à peu
* près constante) plus quatre entrées dérivées par locale (items 8-11 du
* doc-comment ci-dessus) qui concatènent, respectivement, toutes les
* phrases de test d'une locale et toutes les étapes de {@link TEST_RECIPE}
* de cette même locale en un seul step chacune pour isoler l'effet du
* VOLUME de texte sur la durée de traitement de chaque moteur,
* indépendamment de la complexité sémantique déjà couverte par les 7
* premières, sur du texte synthétique ET sur du texte de recette réel.
*/
export const TEST_SENTENCES: readonly BenchmarkSentence[] = [
...BASE_SENTENCES,
{
id: "fr-concat-volumetrie",
locale: "fr",
text: concatenateByLocale("fr"),
note: `Concaténation des ${FR_SENTENCE_COUNT} étapes FR ci-dessus en un seul step — teste si la durée de traitement croît avec le volume de texte, indépendamment de sa complexité.`,
},
{
id: "en-concat-volumetrie",
locale: "en",
text: concatenateByLocale("en"),
note: `Concaténation des ${EN_SENTENCE_COUNT} étapes EN ci-dessus en un seul step — même test de volumétrie côté EN.`,
},
{
id: "fr-recette-complete",
locale: "fr",
text: concatenateRecipeByLocale("fr"),
note: `Les ${TEST_RECIPE.steps.length} étapes de "${TEST_RECIPE.title.fr}" concaténées en un seul step — même test de volumétrie que ci-dessus, mais sur du texte de recette réel plutôt qu'une concaténation de phrases-pièges synthétiques.`,
},
{
id: "en-recette-complete",
locale: "en",
text: concatenateRecipeByLocale("en"),
note: `Les ${TEST_RECIPE.steps.length} étapes de "${TEST_RECIPE.title.en}" concaténées en un seul step — même principe côté EN.`,
},
];

View file

@ -0,0 +1,52 @@
/**
* Typage ambiant minimal pour `node-nlp` (aucun type officiel/DefinitelyTyped
* n'existe) déclare uniquement la surface de `NlpManager` que
* `nlp-tech-step-poc.ts` appelle réellement, vérifié contre le vrai package
* (v4.27.0). Copie volontaire de l'équivalent déjà présent côté
* `apps/api/src/types/node-nlp.d.ts` : ce PoC est délibérément autonome,
* hors du workspace pnpm (voir le commentaire en tête de `README.md`), donc
* ne peut pas importer ce fichier depuis `apps/api`.
*/
declare module "node-nlp" {
/** Constructeur options utilisées ici — `NlpManager` en accepte plus, seules celles utilisées sont typées. */
export interface NlpManagerOptions {
languages?: string[];
forceNER?: boolean;
nlu?: { log?: boolean };
ner?: { threshold?: number };
/** Défaut `true` — persiste le modèle entraîné sur disque (`model.nlp` dans `process.cwd()` par défaut). Toujours `false` ici, voir le constructeur de `NlpTechStepClassifier`. */
autoSave?: boolean;
/** Défaut `true` — charge depuis le fichier au lieu de ré-entraîner s'il existe déjà. Toujours `false` ici, même raison. */
autoLoad?: boolean;
}
/** Une entité rapportée par `NlpManager.process` — sous-ensemble lu par `nlp-tech-step-poc.ts`. */
export interface NlpEntity {
entity: string;
start: number;
end: number;
type: string;
accuracy?: number;
sourceText?: string;
}
/** Résultat de `NlpManager.process` — réduit aux champs lus ici (l'objet réel en porte bien plus). */
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>;
}
}

View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

View file

@ -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;
} }
/** /**

View file

@ -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: {}

View file

@ -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`

View file

@ -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)