batchCooking/apps/api/test/recipe-matching/tech-step-matcher.test.ts
kyuno053 0e0fd81563
feat(api): remplace la détection des tech steps par un pipeline NLP (node-nlp) (#63)
* 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>

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

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

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

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 00:17:39 +02:00

347 lines
15 KiB
TypeScript

import { expect } from "chai";
import { prisma } from "../../src/db/prisma.js";
import {
normalizeText,
splitIntoClauses,
type TechniqueCandidate,
techStepClassifier,
} from "../../src/lib/recipe-matching/tech-step-matcher.js";
import { resetDatabase } from "../../test-support/reset-db.js";
describe("tech-step-matcher", () => {
describe("normalizeText", () => {
it("lowercases and strips accents", () => {
expect(normalizeText("Déglacer AU FOUR")).to.equal("deglacer au four");
});
it("strips a variety of diacritics, including cedilla", () => {
expect(normalizeText("Façon Œuf à l'Étouffée")).to.equal("facon œuf a l'etouffee");
});
it("leaves already-plain text unchanged, aside from casing", () => {
expect(normalizeText("Mix everything")).to.equal("mix everything");
});
it("returns an empty string for an empty input", () => {
expect(normalizeText("")).to.equal("");
});
});
describe("splitIntoClauses", () => {
// A candidate's own `uid` doesn't matter to the splitting logic itself
// (it's opaque, carried through as `anchor`) — kept short and
// arbitrary across these fixtures.
function candidate(uid: string, start: number, end: number): TechniqueCandidate {
return { uid, start, end };
}
it("returns the whole description as one anchor-less clause when there are no candidates", () => {
const text = "Servir immédiatement";
const result = splitIntoClauses(text, []);
expect(result).to.deep.equal([{ start: 0, end: text.length, anchor: null }]);
});
it("returns the whole description as one clause anchored on the single candidate", () => {
const melt = candidate("melt", 6, 13);
const text = "Faire fondre le beurre";
const result = splitIntoClauses(text, [melt]);
expect(result).to.deep.equal([{ start: 0, end: text.length, anchor: melt }]);
});
it("splits into two clauses at the whitespace nearest the gap's midpoint between two candidates", () => {
// "Préchauffer la poêle, puis faire fondre le beurre"
// 0 1 2 3 4
// 0123456789012345678901234567890123456789012345678901
const preheat = candidate("preheat", 0, 11); // "Préchauffer"
const melt = candidate("melt", 27, 39); // "faire fondre"
const text = "Préchauffer la poêle, puis faire fondre le beurre";
const result = splitIntoClauses(text, [preheat, melt]);
expect(result).to.have.length(2);
// The gap between the two candidates is [11, 27) — its raw midpoint
// (19) falls inside "poêle" (see findGapSplitPoint's doc comment for
// why that's specifically what this snaps away from); the nearest
// actual whitespace to that midpoint is the space at 21, right after
// the comma.
expect(result[0]).to.deep.equal({ start: 0, end: 21, anchor: preheat });
expect(result[1]).to.deep.equal({ start: 21, end: text.length, anchor: melt });
// The two clauses are contiguous and cover the whole text.
expect(
text.slice(result[0].start, result[0].end) + text.slice(result[1].start, result[1].end),
).to.equal(text);
});
it("sorts out-of-order candidates before splitting, and anchors each clause on the matching one", () => {
const preheat = candidate("preheat", 0, 11);
const melt = candidate("melt", 27, 39);
// Passed in reverse — the function must still produce clauses in
// reading order, each anchored on the right candidate.
const result = splitIntoClauses("Préchauffer la poêle, puis faire fondre le beurre", [
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("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 () => {
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 () => {
await prisma.$disconnect();
});
describe("matchTechSteps", () => {
it("matches an exact expression", async () => {
expect(
await techStepClassifier.matchTechSteps("Faire mijoter à feu doux", "fr"),
).to.deep.equal([simmerId]);
});
it("is case- and accent-insensitive", async () => {
expect(await techStepClassifier.matchTechSteps("FAIRE MIJOTER", "fr")).to.deep.equal([
simmerId,
]);
});
it("returns an empty sequence when nothing matches", async () => {
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");
});
});
});
});