* feat(recipes): migre la detection des tech steps de node-nlp vers un microservice Python spaCy Remplace TechStepClassifierService's node-nlp (NlpManager) par services/tech-step-intent-service, un microservice FastAPI/spaCy dedie (PhraseMatcher pour le NER par synonymes, textcat pour la classification d'intention). Corpus (TECH_STEP_TRAINING_DATA) toujours possede par apps/api, pousse au service via POST /v1/train a chaque warm-up ; le service ne touche jamais Postgres (meme posture que services/tech-step-llm-worker). Cote apps/api : - intent-service-client.ts : client HTTP vers le nouveau service - tech-step-matcher.ts : delegue NER + intent classification au client, logique pure (splitIntoClauses, seuil/fallback) inchangee - env.ts : INTENT_SERVICE_BASE_URL/INTENT_SERVICE_SECRET (secret requis, service coeur non optionnel) - server.ts : warm-up avec retry/backoff (service Python demarre a part) - scripts/calibrate-tech-step-threshold.ts : recalibration empirique de CONFIDENCE_THRESHOLD contre le jeu d'eval existant - node-nlp retire (package.json, node-nlp.d.ts, model.nlp du .gitignore) docker-compose.yml : nouveau service tech-step-intent-service (pas de port expose, healthcheck, app en depend). CI : job intent-service-test (pytest) + le job test demarre le service en arriere-plan avant la suite Mocha (jamais de mock d'un service interne, cf specs/dev-conventions.md). Verifie : 26/26 tests pytest du service (dont les offsets caracteres exacts de tech-step-matcher.test.ts), lint + build complets du monorepo, smoke test HTTP reel bout en bout. La suite Mocha et docker compose build/up n'ont pas pu etre executes dans cet environnement (pas de Postgres/Docker disponibles ici) — a confirmer via la CI et en local. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): corrige les matches dupliques et le timeout de warm-up des tests CI Deux bugs reels trouves par la premiere execution CI de la migration node-nlp -> tech-step-intent-service : 1. PhraseMatcher retourne tous les matches y compris chevauchants — un synonyme comme "fondre" litteralement contenu dans "faire fondre" (tous deux synonymes de `melt`) produisait deux candidats separes pour la meme technique, dupliquant son techStepId dans le resultat final. Fixe avec spacy.util.filter_spans (garde le plus long match par position) dans LocalePipeline.process. Test de non-regression ajoute. 2. La suite Mocha construit `app` directement via createApp(), sans jamais passer par server.ts — le warm-up (POST /v1/train fr+en sur le corpus complet) se declenchait donc paresseusement dans le premier test qui appelait le classifieur, depassant le timeout Mocha de 10s par test. Fixe par un root hook plugin Mocha (test-support/mocha-root-hooks.ts, .mocharc.json) qui reset la DB et warm up le classifieur une seule fois avant toute suite, avec son propre timeout de 60s. Verifie : 27/27 tests pytest du service (dont le nouveau test de non-regression), lint + build complets du monorepo. La suite Mocha elle-meme n'a toujours pas pu etre executee dans cet environnement (pas de Postgres disponible ici) — a confirmer via la CI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * ci(temp): ajoute un run de calibrate-tech-step-threshold.ts pour observation Etape temporaire pour lire le sweep de seuils de confiance contre le vrai service tech-step-intent-service en CI (aucun Postgres/service disponible localement dans cette session) — sera retiree une fois CONFIDENCE_THRESHOLD recalibre dans tech-step-matcher.ts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): recalibre CONFIDENCE_THRESHOLD pour le nouveau classifieur spaCy 0.75 (calibre a l'origine contre node-nlp) laissait de vrais verdicts corrects sur des clauses sans ancre NER (rien sur quoi retomber) sous le seuil : melt scorait 0.68 sur "jusqu'a ce que le beurre ait disparu dans la poele" (le cas motivant tout ce pipeline), preheat 0.52 sur "mettre la poele sur feu vif" — tous deux corrects, tous deux rejetes a 0.75. Recalibre a 0.45 : marge confortable au-dessus du bruit (texte anglais via le classifieur francais score ~0.04, indiscernable du hasard sur ~26 classes) et sous les deux cas ci-dessus. Confirme par calibrate-tech-step-threshold.ts contre TECH_STEP_EVAL_DATASET (F1 plafonne a 0.987 des 0.45, reste plat jusqu'a 0.95 — 0.45 est deja le seuil le plus bas qui capture tout le gain disponible). Retire l'etape CI temporaire de calibration (ci.yml) une fois la valeur choisie. Verifie : lint + build complets du monorepo, 27/27 pytest du service, sweep de seuils + verification manuelle contre le corpus reel en local (services Python, sans Postgres) et en CI. La suite Mocha complete reste a confirmer sur ce commit (executee en CI, pas localement — pas de Postgres disponible dans cet environnement). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): entraine le textcat plus longtemps pour une confiance reelle Cause racine du dernier test Mocha en echec (getAuditBatch flaguait "Faire mijoter a feu doux" comme peu fiable malgre une ancre NER claire) : avec seulement 30 iterations/dropout 0.2, le textcat retournait le bon intent (argmax correct) mais avec une confiance tres basse et compressee (0.2-0.7 sur l'ensemble du corpus reel, y compris des cas evidents) — un vrai probleme de qualite d'entrainement, pas seulement de seuil. 150 iterations / lot de 16 / dropout 0.1 (mesure localement contre le vrai corpus, sans Postgres) : melt ~0.95, preheat ~0.90, jusqu'a ~0.51 pour le cas le plus faible observe (bake), bruit hors-vocabulaire toujours ~0.05. ~110s d'entrainement par locale (~220s pour fr+en au warm-up) — compromis assume et documente (README du service, commentaires du code), contrairement a l'entrainement quasi instantane de node-nlp. Root hook Mocha (mocha-root-hooks.ts) et sa doc mis a jour avec un timeout de 600s pour couvrir cette duree avec marge. Verifie : 27/27 pytest, lint + build complets du monorepo. Suite Mocha a confirmer sur ce commit via CI (source du diagnostic qui a mene a ce fix). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(recipes): journalise chaque input/output du pipeline NLP Ajoute un logging JSON structure (meme convention que LoggerService cote apps/api) a services/tech-step-intent-service : chaque appel POST /v1/process journalise locale/texte en entree et entites/intent/score en sortie, chaque POST /v1/train journalise les uid entraines et les compteurs resultants. Chatter interne de spaCy mis a WARNING pour ne pas noyer ces lignes. Bug trouve et corrige en verifiant les octets bruts d'un log reel (pas juste son affichage terminal) : l'encodage par defaut de sys.stdout sur Windows produisait de vrais octets UTF-8 invalides pour tout texte accentue journalise (le francais des etapes de recette) — corrige par sys.stdout.reconfigure(encoding="utf-8") au demarrage. LOG_LEVEL configurable (INFO par defaut), documente dans le README du service et .env.example. Verifie : 30/30 pytest (3 nouveaux tests sur le formateur JSON), smoke test HTTP reel confirmant au niveau des octets que les caracteres accentues sont preserves, lint complet du monorepo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(recipes): rapatrie le corpus NLP cote Python et l'enrichit de 48 techniques Changement d'architecture demande par l'utilisateur : le dataset d'entrainement (TECH_STEP_TRAINING_DATA) quitte apps/api pour vivre entierement dans services/tech-step-intent-service (intent_service/training_data.py). Ce service est desormais autonome : il s'entraine lui-meme une seule fois, a son propre demarrage (PipelineRegistry.initialize, dans le lifespan FastAPI), sans plus dependre d'un POST /v1/train pousse par apps/api (route supprimee). apps/api ne connait plus aucune technique/synonyme, uniquement le resultat de POST /v1/process. Corpus enrichi avec les 48 techniques du lexique fourni (Arroser, Appertiser, Braiser, Caraméliser, Confire, Julienne/Brunoise/Mirepoix/ Paysanne, Cuire à blanc/au bain-marie/à l'étouffée, Déglacer variantes, Emulsionner, Glacer, Pocher, Réduire, Suer, Zester, etc.), soit 74 techniques au total (26 + 48). Integration complete bout en bout : - reference-seed-data.ts : 48 nouvelles entrees TECH_STEPS - apps/web/locales/fr/translation.json : libelles francais correspondants - "Mitonner" fondu comme synonyme de simmer (pas une technique distincte, sa propre definition le dit) - "Blanchir un oeuf" (whiskPale) distingue de "Blanchir un legume" (blanch, existant) via des synonymes en phrase complete plutot qu'au mot nu — filter_spans (deja en place) resout la collision par specificite Impact performance mesure : le corpus elargi (74 classes vs 26) rend l'entrainement bien plus lent a nombre d'iterations egal (150 iterations depassait 17 minutes par run de test) — reduit a 40 iterations apres mesures repetees en local (~200s/locale, ~400s pour fr+en combines). docker-compose.yml (healthcheck start_period 600s), CI (timeout curl 600s) et le README du service documentent ce nouveau temps de demarrage. CONFIDENCE_THRESHOLD recalibre a 0.2 par verification manuelle (0.75 puis 0.45 ne tenaient plus compte tenu du nombre de classes) — marque explicitement comme placeholder en attendant une vraie repasse de calibrate-tech-step-threshold.ts (necessite Postgres, indisponible dans cet environnement). Verifie : 28/28 tests pytest du service (suite complete re-ecrite pour s'entrainer une seule fois par session sur le vrai corpus, fixture partagee dans conftest.py), lint + build complets du monorepo. La suite Mocha d'apps/api reste a confirmer via CI (le root hook mocha n'attend plus l'entrainement, seulement CI's propre attente sur /health). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): corrige l'assertion de taille du catalogue TechStep en dur test/reference.test.ts attendait exactement 26 techniques (l'ancien catalogue) au lieu de deriver la longueur attendue de TECH_STEPS (reference-seed-data.ts) — trouve par la CI apres l'ajout des 48 nouvelles techniques (74 au total). Seul echec du run CI precedent, le service Python (nouveau corpus, self-training) a lui demarre et repondu correctement dans le nouveau delai imparti. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): entraine le textcat plus longtemps pour une confiance reelle Suite a une revue de code sur locale_pipeline.py, trois ameliorations implementees et verifiees contre le vrai corpus (74 techniques) : - spacy.util.fix_random_seed(_TRAINING_SEED) avant nlp.initialize() — random.Random() ne graine que l'ordre de melange des exemples, pas l'init des poids/dropout internes de thinc. - _DiacriticsNormalizer deplace au-dessus de sa factory @Language.factory — plus d'annotation de type en chaine. - Log explicite (logger.warning) quand train() recoit moins de 2 labels et saute la creation du textcat, plus une clarification de la docstring de process() sur les deux cas menant a intent=None. - Early stopping avec suivi de la perte par epoque, _TRAINING_ITERATIONS restant le plafond. Mesure sur le vrai corpus : ne se declenche jamais dans le budget actuel de 40 iterations (la perte continue de baisser significativement jusqu'au bout) — documente honnetement comme filet de securite pour un futur relevement du plafond, pas un gain de temps aujourd'hui. Deux suggestions de la revue examinees et non retenues, avec justification en commentaire : le risque de desalignement pattern/texte via normalize_text (normalize_text opere par token deja tokenise, jamais sur la chaine brute — pas de risque de segmentation differente) ; passer a attr="LOWER" aurait au contraire regresse l'insensibilite aux accents que attr="NORM" fournit deliberement. Verifie : 28/28 pytest (dont le vrai corpus complet via la fixture partagee), lint du monorepo. Temps d'entrainement mesure stable (~200-230s/locale, dans la marge de bruit deja documentee). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): entraine le textcat sur les synonymes en plus des utterances Suite a une suggestion de revue de code : le textcat n'apprenait jusqu'ici que sur entry.utterances, jamais sur entry.synonyms (deja utilises pour le PhraseMatcher). Ajouter le mot-cle isole comme exemple positif de sa propre technique ameliore radicalement la confiance sur les cas ancres sans paraphrase entrainee. Mesures sur le vrai corpus (74 techniques) : - 40 iterations + synonymes (749 exemples vs 286 avant) : gain de confiance massif (simmer 0.25->0.60, cook 0.33->0.60, bake 0.34->0.86) mais temps d'entrainement multiplie par 2.6 (~535s/locale, ~17min combine pour fr+en — inacceptable). - 15 iterations + synonymes : retour a un temps raisonnable (~205s) mais qualite pire qu'avant (simmer/cook repassent sous le seuil de confiance) — les exemples supplementaires ne compensent pas la perte d'epoques a ce point. - 25 iterations + synonymes (retenu) : ~336s/locale (~670s combine), meilleur compromis — tous les cas mesures s'ameliorent par rapport a la config precedente (simmer 0.25->0.31, cook 0.33->0.38, bake 0.34->0.62, zest 0.64->0.66, julienne 0.56->0.76, compote 0.76->0.78), bruit hors-vocabulaire toujours negligeable (~0.02). CONFIDENCE_THRESHOLD releve de 0.2 a 0.25 (le cas le plus faible mesure est maintenant 0.31, avec plus de marge qu'avant). docker-compose.yml (start_period 900s) et la CI (timeout 900s) ajustes pour le nouveau temps de demarrage (~11 min pour fr+en combines, contre ~7 min avant). Deux autres pistes de la meme revue examinees et non retenues avec justification : classe __OTHER__/negatifs hors-domaine (le bruit mesure est deja bas, ~0.02, sans le symptome que cette classe corrige) et boost de score post-traitement si le NER confirme l'intention predite (casserait la garantie "score brut, jamais corrige par l'ancre" que services/tech-step-llm-worker's audit de faible confiance depend explicitement d'avoir, voir le commentaire de TechStepClauseClassification dans tech-step-matcher.ts). Verifie : 28/28 pytest (dont le vrai corpus complet, ~10.5 min pour la suite complete), lint + build du monorepo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
350 lines
15 KiB
TypeScript
350 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
|
|
// (`services/tech-step-intent-service`'s `training_data.py`) and the
|
|
// real seeded `TechStep` catalog, rather than synthetic injectable
|
|
// fixtures the old regex-based `matchTechStepSpans(description,
|
|
// mappings)` allowed. Every call round-trips over HTTP to a real,
|
|
// locally running `services/tech-step-intent-service` (see that
|
|
// service's own README and `apps/api/.env.test`) — that service trains
|
|
// itself once at its own startup (`test-support/mocha-root-hooks.ts`'s
|
|
// root hook doesn't wait on it, CI's own "wait for /health" step
|
|
// already does), so calls here are just a normal HTTP round-trip,
|
|
// 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");
|
|
});
|
|
});
|
|
});
|
|
});
|