From 065ef2a31a845b6a1b3785abc29f8f7d64bfee9c Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 25 Aug 2026 22:59:13 +0200 Subject: [PATCH] feat(recipes): rapatrie le corpus NLP cote Python et l'enrichit de 48 techniques MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 10 +- README.md | 6 +- apps/api/src/db/reference-seed-data.ts | 62 +- .../recipe-matching/intent-service-client.ts | 35 +- .../recipe-matching/tech-step-eval-dataset.ts | 6 +- .../recipe-matching/tech-step-evaluator.ts | 5 +- .../lib/recipe-matching/tech-step-matcher.ts | 156 +- .../tech-step-training-data.ts | 911 --------- apps/api/src/scripts/backfill-tech-steps.ts | 4 +- .../list-pending-training-suggestions.ts | 9 +- apps/api/src/scripts/retrain-tech-steps.ts | 11 +- apps/api/test-support/mocha-root-hooks.ts | 46 +- .../recipe-translation.test.ts | 5 +- .../recipe-matching/tech-step-matcher.test.ts | 19 +- .../recipe-tech-step-correction.test.ts | 2 +- apps/web/src/locales/fr/translation.json | 50 +- docker-compose.yml | 16 +- services/tech-step-intent-service/README.md | 105 +- .../intent_service/config.py | 5 +- .../intent_service/locale_pipeline.py | 55 +- .../intent_service/logging_config.py | 5 +- .../intent_service/main.py | 23 +- .../intent_service/pipeline_registry.py | 58 +- .../intent_service/routes/train.py | 58 - .../intent_service/schemas.py | 40 +- .../intent_service/training_data.py | 1692 +++++++++++++++++ .../tests/conftest.py | 25 +- .../tests/test_logging_config.py | 5 +- .../tests/test_routes_process.py | 68 +- .../tests/test_routes_train.py | 47 - .../tests/test_security.py | 13 +- specs/backend-architecture.md | 34 +- specs/batch-cooking-modele.md | 6 +- 33 files changed, 2199 insertions(+), 1393 deletions(-) delete mode 100644 apps/api/src/lib/recipe-matching/tech-step-training-data.ts delete mode 100644 services/tech-step-intent-service/intent_service/routes/train.py create mode 100644 services/tech-step-intent-service/intent_service/training_data.py delete mode 100644 services/tech-step-intent-service/tests/test_routes_train.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba6ceff..57666e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,7 +94,15 @@ jobs: working-directory: services/tech-step-intent-service run: | uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 & - timeout 60 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 1; done' + # `/health` only returns 200 once this service has finished + # training itself from scratch (no model ever persisted to disk — + # see its own README) — measured at ~200s per locale (~400s for + # fr+en combined) against the current ~74-technique corpus, so + # this wait is generous rather than the fast "base models only" + # check it used to be before that service trained itself at + # startup (see docker-compose.yml's healthcheck for the same + # reasoning). + timeout 600 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done' - run: pnpm install --frozen-lockfile - run: pnpm --filter api exec prisma migrate deploy diff --git a/README.md b/README.md index 9764fe6..d4fdc2d 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,10 @@ pnpm --filter api exec prisma migrate dev pnpm --filter api prisma:seed # Microservice de détection des techniques (spaCy) — requis, `pnpm dev:api` -# ne peut plus détecter aucune technique de cuisine sans lui (voir son -# propre README pour le détail) +# ne peut plus détecter aucune technique de cuisine sans lui. Lance-le en +# premier et laisse-le tourner : il s'entraîne lui-même à chaque démarrage +# (~7 minutes pour le corpus actuel, voir son propre README) avant de +# répondre quoi que ce soit sur /health. cd services/tech-step-intent-service uv sync cp .env.example .env # édite-le : même INTENT_SERVICE_SECRET que apps/api/.env diff --git a/apps/api/src/db/reference-seed-data.ts b/apps/api/src/db/reference-seed-data.ts index ac5cf75..bc3920f 100644 --- a/apps/api/src/db/reference-seed-data.ts +++ b/apps/api/src/db/reference-seed-data.ts @@ -62,11 +62,12 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }> // // Just a flat list of stable ids here — the actual matching data (per- // locale synonym lists + example phrasings the classifier trains on) lives -// in `lib/recipe-matching/tech-step-training-data.ts`'s -// `TECH_STEP_TRAINING_DATA`, not here: unlike this list, it's read by -// `TechStepClassifierService`'s training pass, not the seed script, so it -// doesn't belong alongside the rest of this file's DB-seeded reference -// data. Every entry here must have a matching entry there. +// in `services/tech-step-intent-service/intent_service/training_data.py`'s +// `TECH_STEP_TRAINING_DATA`, not here: it's owned and trained entirely by +// that separate Python service (see its own README), not read by this +// seed script at all, so it doesn't belong alongside the rest of this +// file's DB-seeded reference data. Every entry here must have a matching +// entry there. export const TECH_STEPS: string[] = [ "cook", "fry", @@ -94,6 +95,57 @@ export const TECH_STEPS: string[] = [ "bake", "plate", "coat", + // Lexique de techniques ajouté par la suite — voir + // `services/tech-step-intent-service/intent_service/training_data.py` + // pour les synonymes/phrases d'exemple de chacune. + "baste", + "appertize", + "whiskPale", + "goldenBrown", + "braise", + "truss", + "caramelize", + "score", + "lineMold", + "clarify", + "compote", + "concasse", + "confit", + "julienne", + "brunoise", + "mirepoix", + "paysanne", + "blindBake", + "bainMarie", + "smother", + "decant", + "dilute", + "punchDown", + "disgorge", + "loosen", + "shellEgg", + "scald", + "pod", + "emulsify", + "hollowOut", + "shock", + "setGel", + "glaze", + "thicken", + "filet", + "proof", + "peelBlanch", + "whipUp", + "moisten", + "pasteurize", + "poach", + "reduce", + "rubIn", + "dustWithFlour", + "sweat", + "sift", + "toast", + "zest", ]; // The 14 allergens EU Regulation 1169/2011 (Annex II) requires food diff --git a/apps/api/src/lib/recipe-matching/intent-service-client.ts b/apps/api/src/lib/recipe-matching/intent-service-client.ts index bb8f262..0376d6c 100644 --- a/apps/api/src/lib/recipe-matching/intent-service-client.ts +++ b/apps/api/src/lib/recipe-matching/intent-service-client.ts @@ -2,11 +2,14 @@ import { env } from "../../config/env.js"; /** * Thin fetch wrapper around `services/tech-step-intent-service`'s HTTP - * contract (`/v1/train`, `/v1/process`) — the microservice + * contract (`POST /v1/process`) — the microservice * {@link TechStepClassifierService} (`tech-step-matcher.ts`) delegates NER + * intent classification to, in place of the `node-nlp` `NlpManager` it used * to own directly. See that service's own README for the full contract and - * why it never touches Postgres itself. + * why it never touches Postgres itself — it also owns its own training + * corpus now (`training_data.py`), trained once at its own startup, so + * `apps/api` never pushes anything to it; `process()` below is this + * client's only method. * * Authenticated with `INTENT_SERVICE_SECRET` — the inverse direction of * `requireInternalWorker`'s `INTERNAL_WORKER_SECRET` (this time `apps/api` @@ -21,20 +24,13 @@ export interface IntentServiceEntity { end: number; } -/** The full result of a `POST /v1/process` call — mirrors `ProcessResponse` (Python `schemas.py`). `intent` is `null` only when `locale` was never trained or `text` is blank; otherwise always a real `uid` (the Python service's `textcat` has no "None" sentinel, unlike node-nlp — see that service's README). */ +/** The full result of a `POST /v1/process` call — mirrors `ProcessResponse` (Python `schemas.py`). `intent` is `null` only when `locale` isn't one this service trains for, or `text` is blank; otherwise always a real `uid` (the Python service's `textcat` has no "None" sentinel, unlike node-nlp — see that service's README). */ export interface IntentServiceProcessResult { entities: IntentServiceEntity[]; intent: string | null; score: number; } -/** One technique's training data for one locale, as sent to `POST /v1/train` — mirrors `TrainEntryPayload` (Python `schemas.py`), itself shaped after `TechStepLocaleTrainingData` (`tech-step-training-data.ts`). */ -export interface IntentServiceTrainEntry { - uid: string; - synonyms: string[]; - utterances: string[]; -} - /** * Client for `services/tech-step-intent-service` — a real class (not a * plain object of functions) per this repo's service-style-logic @@ -81,25 +77,6 @@ export class IntentServiceClient { } } - /** - * (Re)trains the intent service's pipeline for `locale` from `entries` — - * called once per locale by `TechStepClassifierService._train`, itself - * memoized so this only ever runs once per server process (see that - * method's own doc comment). Reconstructs the whole pipeline server-side, - * never a partial/incremental update — same "always retrains fresh from - * the one source of truth" posture the old in-process `NlpManager` had. - */ - public async train(locale: string, entries: IntentServiceTrainEntry[]): Promise { - try { - await this._request("/v1/train", { - method: "POST", - body: JSON.stringify({ locale, entries }), - }); - } catch (err) { - throw err; - } - } - /** * Equivalent to the old `NlpManager.process(locale, text)` — returns every * candidate technique mention (NER) plus the intent classifier's verdict diff --git a/apps/api/src/lib/recipe-matching/tech-step-eval-dataset.ts b/apps/api/src/lib/recipe-matching/tech-step-eval-dataset.ts index 8a0d951..008ab35 100644 --- a/apps/api/src/lib/recipe-matching/tech-step-eval-dataset.ts +++ b/apps/api/src/lib/recipe-matching/tech-step-eval-dataset.ts @@ -2,8 +2,8 @@ * Hand-labeled evaluation set for {@link techStepClassifier} — what * `tech-step-eval.test.ts` runs the real classifier against to compute * precision/recall/F1 (`tech-step-evaluator.ts`), the objective gate any - * future change to `tech-step-training-data.ts` must clear (see that - * module's own doc comment). + * future change to `services/tech-step-intent-service`'s `training_data.py` + * must clear (see that module's own doc comment). * * Deliberately *not* reusing `TECH_STEP_TRAINING_DATA`'s own `utterances` * verbatim — scoring the classifier against the exact sentences it was @@ -246,7 +246,7 @@ export const TECH_STEP_EVAL_DATASET: TechStepEvalCase[] = [ // --- Documented false-positive traps, re-verified with fresh wording --- // `brown`'s EN synonyms are verb forms only ("browned"/"browning"), not // bare "brown" — precisely so this doesn't false-positive (see that - // entry's own comment in tech-step-training-data.ts). + // entry's own comment in training_data.py). { description: "This recipe calls for two tablespoons of brown sugar.", locale: "en", diff --git a/apps/api/src/lib/recipe-matching/tech-step-evaluator.ts b/apps/api/src/lib/recipe-matching/tech-step-evaluator.ts index 6158189..fc25846 100644 --- a/apps/api/src/lib/recipe-matching/tech-step-evaluator.ts +++ b/apps/api/src/lib/recipe-matching/tech-step-evaluator.ts @@ -3,8 +3,9 @@ * hand-labeled evaluation set (`tech-step-eval-dataset.ts`) — the objective * counterpart to the "inspected by eye" verdict every corpus change used to * get before this module existed. Every future edit to - * `tech-step-training-data.ts` (including the LLM-assisted suggestions the - * worker in `services/tech-step-llm-worker` proposes) is expected to run + * `services/tech-step-intent-service`'s `training_data.py` (including the + * LLM-assisted suggestions the worker in `services/tech-step-llm-worker` + * proposes) is expected to run * through `tech-step-eval.test.ts`'s regression gate, which calls * {@link computeTechStepMetrics} — a corpus change that raises recall on one * technique but silently tanks another's precision should fail loudly here, diff --git a/apps/api/src/lib/recipe-matching/tech-step-matcher.ts b/apps/api/src/lib/recipe-matching/tech-step-matcher.ts index e72bd0b..af62d54 100644 --- a/apps/api/src/lib/recipe-matching/tech-step-matcher.ts +++ b/apps/api/src/lib/recipe-matching/tech-step-matcher.ts @@ -1,6 +1,5 @@ import { prisma } from "../../db/prisma.js"; import { intentServiceClient } from "./intent-service-client.js"; -import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js"; /** * Auto-detects which cooking techniques (`TechStep`) a free-text recipe @@ -15,13 +14,14 @@ import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js"; * generalize past its own vocabulary — a step describing melting butter as * "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 (originally built on `node-nlp`, now delegated to - * `services/tech-step-intent-service` — a spaCy-based microservice, see - * {@link IntentServiceClient} and that service's own README): + * small hybrid pipeline (originally built on `node-nlp`, now entirely + * delegated to `services/tech-step-intent-service` — a spaCy-based + * microservice, see {@link IntentServiceClient} and that service's own + * README): * - * 1. **NER** (the intent service's `PhraseMatcher`, built from `synonyms` in - * `TECH_STEP_TRAINING_DATA`) finds every *candidate* technique mention in - * the whole description, each with its exact character span — + * 1. **NER** (the intent service's `PhraseMatcher`, built from its own + * `training_data.py`'s `synonyms`) 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. This step alone is *not* the * final answer — see step 3. @@ -31,7 +31,7 @@ import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js"; * and `melt`) needs each judged on its own surrounding context, not the * whole step lumped into one classification. * 3. **NLP intent classification** (the intent service's `textcat`, trained - * on `TECH_STEP_TRAINING_DATA`'s `utterances`) then classifies each + * on its own `training_data.py`'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 @@ -50,12 +50,12 @@ import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js"; * * `normalizeText` and {@link splitIntoClauses} are pure (no DB/model * access) so they stay unit-testable in isolation (see - * `test/tech-step-matcher.test.ts`); the classifier itself needs a one-time - * training pass (`_ensureTrained`, a `POST /v1/train` call per locale to the - * intent service) plus a `TechStep.key -> id` lookup from the DB, both - * memoized on the shared {@link techStepClassifier} singleton rather than - * repeated per call — training is the expensive part, never worth redoing - * per request let alone per step. + * `test/tech-step-matcher.test.ts`); this class only ever needs a + * `TechStep.key -> id` lookup from the DB, memoized on the shared + * {@link techStepClassifier} singleton rather than repeated per call — the + * NLP model itself trains once, inside `services/tech-step-intent-service`'s + * own startup, entirely independently of this class (see that service's + * README — this repo no longer pushes any corpus to it over HTTP). */ /** @@ -242,32 +242,29 @@ export function splitIntoClauses( * `TECH_STEP_TRAINING_DATA` — see `test/tech-step-matcher.test.ts` for the * cases this threshold was picked to pass. * - * Recalibrated to `0.45` for the migration off `node-nlp` to - * `services/tech-step-intent-service` (spaCy `textcat`, exclusive classes - * over ~26 techniques) — its score distribution is meaningfully different - * from node-nlp's own classifier. With that service's training tuned for - * real confidence rather than just correct argmax (see - * `_TRAINING_ITERATIONS`/`_TRAINING_DROPOUT` in - * `services/tech-step-intent-service/intent_service/locale_pipeline.py`), - * genuine matches score comfortably above `0.45` with real margin: `melt` - * scores `~0.95` on "jusqu'à ce que le beurre ait disparu dans la poêle" - * (the exact motivating no-keyword case this pipeline exists for, no NER - * anchor to fall back to), `preheat` `~0.90` on "mettre la poêle sur feu - * vif", down to `~0.51` for the weakest real case seen (`bake`, anchored). - * The noise floor stays far below all of them: English recipe text run - * through the French classifier (must find *nothing*, confirmed by - * `recipe-translation.test.ts`'s own locale-isolation test) scores `~0.05` - * for every technique — indistinguishable from the ~1/26 uniform baseline - * over this many exclusive classes. Cross-checked against - * `apps/api/src/scripts/calibrate-tech-step-threshold.ts`'s sweep over - * `TECH_STEP_EVAL_DATASET`: aggregate F1 climbs to its plateau (`0.987`) - * starting at `0.45` and stays flat through `0.95`, so this is the lowest - * threshold that already captures every gain available from trusting the - * classifier more — a higher value would only ever risk discarding a - * genuine anchor-less match like the two above, never buy back any - * precision. + * Recalibrated for the migration off `node-nlp` to + * `services/tech-step-intent-service` (spaCy `textcat`, exclusive classes) + * — its score distribution is meaningfully different from node-nlp's own + * classifier, and shifts again every time the corpus' technique count + * changes (more exclusive classes generally means a *lower* natural + * confidence ceiling, softmax mass spread thinner). + * + * Currently `0.2`, set against the corpus as expanded to ~74 techniques + * (`services/tech-step-intent-service/intent_service/training_data.py`, + * `_TRAINING_ITERATIONS = 40`) from manual spot-checks, not yet a real + * `calibrate-tech-step-threshold.ts` sweep against + * `TECH_STEP_EVAL_DATASET` (needs Postgres — see that script's own doc + * comment): observed real-case scores ranged `0.25`-`0.89` (`simmer` + * lowest, still correct and anchored anyway; `melt` highest, the + * motivating anchor-less case), against a noise floor around `0.02` + * (English text through the French classifier). `0.2` sits with margin + * above the noise floor and below every real case seen so far, but **this + * is a placeholder pending the real eval-dataset sweep** — do not treat it + * as load-bearing precision the way the previous `0.45` (calibrated + * against the ~26-technique corpus, `TECH_STEP_EVAL_DATASET` F1 plateauing + * exactly there) was. */ -export const CONFIDENCE_THRESHOLD = 0.45; +export const CONFIDENCE_THRESHOLD = 0.2; /** * One clause's full classification detail — the finer-grained sibling of @@ -295,29 +292,34 @@ export interface TechStepClauseClassification { } /** - * Owns the trained state 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 - * memoized training/lookup promises), not just grouped stateless helpers. - * The actual NER/intent-classification model lives in - * `services/tech-step-intent-service` (a separate process) — this class's - * own state is just what it needs to talk to that service correctly - * (whether training has been kicked off yet, and the `TechStep.key -> id` - * lookup that service's `uid`s must still be resolved through). + * Owns the `TechStep.key -> id` lookup 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 memoized lookup promise), not just grouped stateless helpers. + * The actual NER/intent-classification model lives entirely in + * `services/tech-step-intent-service` (a separate process, trained from + * its own `training_data.py` at its own startup) — this class never + * trains or pushes anything to it, it only calls `POST /v1/process` and + * resolves whatever `uid` comes back to a local DB id. */ export class TechStepClassifierService { - /** 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 | 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. */ + /** Memoized `TechStep.key -> id` lookup — resolved from the DB once, reused by every call rather than queried per request. `undefined` until the first call starts loading it, after which every caller (concurrent or not) awaits the same promise. */ + private _techStepIdsLoaded: Promise | undefined; private _techStepIdByUid: Map | undefined; /** - * Forces training (a `POST /v1/train` call per locale to - * `services/tech-step-intent-service`) to happen now, synchronously with + * Forces the `TechStep.key -> id` lookup to load now, synchronously with * server startup (see `server.ts`, which also retries this against a - * not-yet-ready intent service), rather than stalling whichever request - * happens to be first to save/preview a recipe. + * not-yet-reachable intent service), rather than stalling whichever + * request happens to be first to save/preview a recipe. Doesn't wait on + * `services/tech-step-intent-service` finishing its own training — that + * service is only ever considered "up" by Docker Compose/CI once it + * already is (see that service's `GET /health`), so by the time this + * runs in a real deployment it's already trained; a request racing an + * intent service that's genuinely still starting just gets an empty + * match list back (see `IntentServiceProcessResult`'s own doc comment), + * not an error. */ public async warmUp(): Promise { try { @@ -341,7 +343,7 @@ export class TechStepClassifierService { */ public async matchTechStepSpans(description: string, locale: string): Promise { try { - await this._ensureTrained(); + await this._ensureTechStepIdsLoaded(); if (description.trim().length === 0) return []; // The intent service only ever returns enum-style candidates (its own @@ -405,7 +407,7 @@ export class TechStepClassifierService { locale: string, ): Promise { try { - await this._ensureTrained(); + await this._ensureTechStepIdsLoaded(); if (description.trim().length === 0) return []; const nerResult = await intentServiceClient.process(locale, description); @@ -491,46 +493,34 @@ export class TechStepClassifierService { } /** - * Trains `services/tech-step-intent-service` 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. + * Resolves the `uid -> TechStep.id` lookup exactly once — memoized on + * `_techStepIdsLoaded` 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 DB query rather than each firing their own. */ - private async _ensureTrained(): Promise { - if (this._trained === undefined) { - this._trained = this._train(); + private async _ensureTechStepIdsLoaded(): Promise { + if (this._techStepIdsLoaded === undefined) { + this._techStepIdsLoaded = this._loadTechStepIds(); } try { - await this._trained; + await this._techStepIdsLoaded; } 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; + // A failed load must be retried by the *next* call, not leave every + // future call permanently rejecting against a stale failed promise. + this._techStepIdsLoaded = undefined; throw err; } } - private async _train(): Promise { + private async _loadTechStepIds(): Promise { 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 locale of ["fr", "en"] as const) { - const entries = TECH_STEP_TRAINING_DATA.map((entry) => ({ - uid: entry.uid, - synonyms: entry[locale].synonyms, - utterances: entry[locale].utterances, - })); - await intentServiceClient.train(locale, entries); - } } catch (err) { throw err; // see matchTechStepSpans()'s catch comment above } } } -/** Single shared instance — training is expensive enough (a couple of minutes total, both locales combined — see `services/tech-step-intent-service`'s own `_TRAINING_ITERATIONS`) that every caller must reuse the one already-trained model, never spin up their own. */ +/** Single shared instance — every caller reuses the one memoized `TechStep.key -> id` lookup rather than re-querying the DB. The actual model training (expensive — a couple of minutes, both locales combined) happens entirely inside `services/tech-step-intent-service`'s own startup, not here — see that service's `_TRAINING_ITERATIONS`. */ export const techStepClassifier = new TechStepClassifierService(); diff --git a/apps/api/src/lib/recipe-matching/tech-step-training-data.ts b/apps/api/src/lib/recipe-matching/tech-step-training-data.ts deleted file mode 100644 index b4e6019..0000000 --- a/apps/api/src/lib/recipe-matching/tech-step-training-data.ts +++ /dev/null @@ -1,911 +0,0 @@ -/** - * 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", - ], - }, - }, -]; diff --git a/apps/api/src/scripts/backfill-tech-steps.ts b/apps/api/src/scripts/backfill-tech-steps.ts index 5facc3e..45753aa 100644 --- a/apps/api/src/scripts/backfill-tech-steps.ts +++ b/apps/api/src/scripts/backfill-tech-steps.ts @@ -6,8 +6,8 @@ import { renumberStepTechSteps } from "../modules/recipe/recipe-tech-step-correc /** * Recomputes every existing `Step`'s `"auto"`-sourced `StepTechStep` * entries 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 — + * (`tech-step-matcher.ts`, delegating to `services/tech-step-intent-service`), + * 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 diff --git a/apps/api/src/scripts/list-pending-training-suggestions.ts b/apps/api/src/scripts/list-pending-training-suggestions.ts index 17089a3..08aa8b6 100644 --- a/apps/api/src/scripts/list-pending-training-suggestions.ts +++ b/apps/api/src/scripts/list-pending-training-suggestions.ts @@ -6,14 +6,15 @@ import { prisma } from "../db/prisma.js"; * comment) — generated by `services/tech-step-llm-worker`'s scheduled * jobs, from either a user correction or the worker's own low-confidence * audit (`sourceType`). What a maintainer reads *before* hand-editing - * `tech-step-training-data.ts` and running `retrain-tech-steps.ts` — this - * script never writes anything, purely a read-only report to stdout: + * `services/tech-step-intent-service/intent_service/training_data.py` and + * running `retrain-tech-steps.ts` — this script never writes anything, + * purely a read-only report to stdout: * * pnpm --filter api exec tsx src/scripts/list-pending-training-suggestions.ts * * Grouped by technique key so every suggestion for the same entry in - * `TECH_STEP_TRAINING_DATA` is read together, matching how that file - * itself is organized (one block per technique). + * `training_data.py`'s `TECH_STEP_TRAINING_DATA` is read together, matching + * how that file itself is organized (one block per technique). */ async function listPendingTrainingSuggestions(): Promise { const suggestions = await prisma.techStepTrainingSuggestion.findMany({ diff --git a/apps/api/src/scripts/retrain-tech-steps.ts b/apps/api/src/scripts/retrain-tech-steps.ts index 0e870e7..fbeebdf 100644 --- a/apps/api/src/scripts/retrain-tech-steps.ts +++ b/apps/api/src/scripts/retrain-tech-steps.ts @@ -28,10 +28,15 @@ function parseSuggestionIds(flag: "applied" | "rejected"): number[] { * Maintainer workflow closing the loop on a training-corpus change (see * this feature's plan document): * - * 1. A maintainer has already hand-edited `tech-step-training-data.ts` - * (informed by `list-pending-training-suggestions.ts`'s report), and + * 1. A maintainer has already hand-edited + * `services/tech-step-intent-service/intent_service/training_data.py` + * (informed by `list-pending-training-suggestions.ts`'s report), * decided which `TechStepTrainingSuggestion` ids they incorporated - * (`--applied=`) or explicitly discarded (`--rejected=`). + * (`--applied=`) or explicitly discarded (`--rejected=`), **and + * restarted `tech-step-intent-service`** so it retrains from the + * edited corpus — that service only ever trains once, at its own + * startup (see its README), so this script's eval gate below is + * meaningless against a service still running the old corpus. * 2. This script re-runs the F1 regression gate * ({@link runTechStepEvalSuite} against {@link MIN_OVERALL_F1}) — * refuses to backfill at all if the edited corpus scores worse than diff --git a/apps/api/test-support/mocha-root-hooks.ts b/apps/api/test-support/mocha-root-hooks.ts index 488439a..240b0a4 100644 --- a/apps/api/test-support/mocha-root-hooks.ts +++ b/apps/api/test-support/mocha-root-hooks.ts @@ -5,27 +5,23 @@ import { resetDatabase } from "./reset-db.js"; * Mocha root hook plugin (see `.mocharc.json`'s `require`) — runs once * before every test file's own suites, regardless of load order. * - * Warms up `techStepClassifier` here, with its own generous timeout, + * Warms up `techStepClassifier` here — resolving the `TechStep.key -> id` + * lookup from the DB (see `TechStepClassifierService._loadTechStepIds`) — * instead of leaving it to happen lazily on whichever test file Mocha - * happens to load first. In production this one-time cost (a `POST - * /v1/train` round-trip per locale to `services/tech-step-intent-service`, - * training a real `textcat` on the full `TECH_STEP_TRAINING_DATA` corpus) - * is paid by `server.ts`'s own `techStepClassifier.warmUp()` before the - * server ever accepts traffic — but this test suite builds its `app` - * directly via `createApp()` (see e.g. `tech-step-worker.routes.test.ts`), - * never running `server.ts` at all. Without this hook, that cost instead - * landed inside whichever test's own call happened to trigger - * `_ensureTrained()` first — found the hard way in CI, where training the - * full corpus took longer than a single test's default 10s timeout - * (`.mocharc.json`) and failed an otherwise-unrelated test purely because - * Mocha loaded its file first alphabetically. + * happens to load first, same as `server.ts` does before the real server + * ever accepts traffic. Fast by itself (one DB query, one HTTP call to + * `services/tech-step-intent-service`): that service now trains itself + * entirely at its own process startup (see its own README), so unlike + * before this migration, nothing here waits on a slow training pass — CI's + * own "wait for `/health`" step (`.github/workflows/ci.yml`) is what + * ensures that service is already fully trained before `pnpm --filter api + * test` even starts. * - * `resetDatabase()` runs first, deliberately: `_train()` - * (`tech-step-matcher.ts`) resolves `TechStep.key -> id` from the database - * alongside training, and a freshly-migrated (never-seeded) test database - * has no `TechStep` rows yet — every per-test `beforeEach` in this suite - * already calls `resetDatabase()` again before its own test, which is a - * no-op duplication of effort but not a correctness problem: `TRUNCATE ... + * `resetDatabase()` runs first, deliberately: id resolution needs + * `TechStep` rows, and a freshly-migrated (never-seeded) test database has + * none yet. Every per-test `beforeEach` in this suite already calls + * `resetDatabase()` again before its own test, which is a no-op + * duplication of effort but not a correctness problem: `TRUNCATE ... * RESTART IDENTITY` plus deterministic re-seeding (`seedReferenceData`) * assigns the exact same ids every time, so the `uid -> id` map memoized * here from this first reset stays valid for every reset after it. @@ -33,13 +29,11 @@ import { resetDatabase } from "./reset-db.js"; export const mochaHooks = { // biome-ignore lint/suspicious/noExplicitAny: Mocha's root hook `this` (a Context with `.timeout()`) isn't typed without @types/mocha (not a dependency here) — same untyped-`this` shape already used in tech-step-worker.routes.test.ts. async beforeAll(this: any): Promise { - // Generous on purpose: training both locales' `textcat` on the full - // corpus takes on the order of a couple of minutes combined (see - // `_TRAINING_ITERATIONS` in `services/tech-step-intent-service`'s - // `locale_pipeline.py`) — comfortably under 10 minutes even on a - // slower/contended CI runner, but nowhere near Mocha's normal 10s - // per-test default (`.mocharc.json`). - this.timeout(600000); + // A little more generous than Mocha's normal 10s per-test default + // (`.mocharc.json`) purely for a slower/contended CI runner's first + // network round-trip to `services/tech-step-intent-service` — not + // because anything here waits on training anymore. + this.timeout(30000); await resetDatabase(); await techStepClassifier.warmUp(); }, diff --git a/apps/api/test/recipe-matching/recipe-translation.test.ts b/apps/api/test/recipe-matching/recipe-translation.test.ts index 6cc3460..e1a31f3 100644 --- a/apps/api/test/recipe-matching/recipe-translation.test.ts +++ b/apps/api/test/recipe-matching/recipe-translation.test.ts @@ -38,8 +38,9 @@ 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 + // the real training corpus (`services/tech-step-intent-service`'s + // `training_data.py`) 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", () => { diff --git a/apps/api/test/recipe-matching/tech-step-matcher.test.ts b/apps/api/test/recipe-matching/tech-step-matcher.test.ts index be17083..bf667de 100644 --- a/apps/api/test/recipe-matching/tech-step-matcher.test.ts +++ b/apps/api/test/recipe-matching/tech-step-matcher.test.ts @@ -118,15 +118,16 @@ describe("tech-step-matcher", () => { // `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 now 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`) — the very first call in the whole suite - // pays for that plus the service's own spaCy pipeline setup (subsequent - // calls reuse the already-trained pipeline and are fast) — comfortably - // inside this suite's default 10s timeout (.mocharc.json). + // (`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; diff --git a/apps/api/test/recipe/recipe-tech-step-correction.test.ts b/apps/api/test/recipe/recipe-tech-step-correction.test.ts index 240c218..d4fff8a 100644 --- a/apps/api/test/recipe/recipe-tech-step-correction.test.ts +++ b/apps/api/test/recipe/recipe-tech-step-correction.test.ts @@ -79,7 +79,7 @@ describe("Recipe tech-step corrections", () => { const { agent, profileId } = await signup(); // "Faire mijoter la sauce." names no technique the classifier itself // registers a bare-word anchor for at this exact span in isolation - // (see tech-step-training-data.ts) — irrelevant here either way, + // (see services/tech-step-intent-service's training_data.py) — irrelevant here either way, // since this test's whole point is the *manual* addition, not // whatever the classifier does or doesn't auto-detect for it. const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index e2769d5..2d2e583 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -425,7 +425,55 @@ "preheat": "Préchauffer", "bake": "Cuire au four", "plate": "Dresser", - "coat": "Napper" + "coat": "Napper", + "baste": "Arroser", + "appertize": "Appertiser", + "whiskPale": "Blanchir (jaunes d'œufs)", + "goldenBrown": "Blondir", + "braise": "Braiser", + "truss": "Brider", + "caramelize": "Caraméliser", + "score": "Cerner", + "lineMold": "Chemiser", + "clarify": "Clarifier", + "compote": "Compoter", + "concasse": "Concasser", + "confit": "Confire", + "julienne": "Couper en julienne", + "brunoise": "Couper en brunoise", + "mirepoix": "Couper en mirepoix", + "paysanne": "Couper en paysanne", + "blindBake": "Cuire à blanc", + "bainMarie": "Cuire au bain-marie", + "smother": "Cuire à l'étouffée", + "decant": "Décanter", + "dilute": "Délayer", + "punchDown": "Dégazer", + "disgorge": "Dégorger", + "loosen": "Détendre", + "shellEgg": "Écaler", + "scald": "Échauder", + "pod": "Écosser", + "emulsify": "Émulsionner", + "hollowOut": "Évider", + "shock": "Frapper", + "setGel": "Gélifier", + "glaze": "Glacer", + "thicken": "Lier", + "filet": "Lever les filets", + "proof": "Laisser pousser", + "peelBlanch": "Monder", + "whipUp": "Monter", + "moisten": "Mouiller", + "pasteurize": "Pasteuriser", + "poach": "Pocher", + "reduce": "Réduire", + "rubIn": "Sabler", + "dustWithFlour": "Singer", + "sweat": "Suer", + "sift": "Tamiser", + "toast": "Torréfier", + "zest": "Zester" }, "allergens": { "gluten": "Gluten", diff --git a/docker-compose.yml b/docker-compose.yml index 2dd9c22..797ad14 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -88,10 +88,20 @@ services: "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2)", ] - interval: 10s + interval: 15s timeout: 3s - retries: 10 - start_period: 15s + retries: 5 + # This service trains itself from scratch on every start (no model + # ever persisted to disk, see its own README) — `/health` only + # returns 200 once that's done, not just once the base spaCy models + # are loaded. Measured at ~200s per locale (~400s for fr+en combined) + # against the current ~74-technique corpus + # (`intent_service/training_data.py`) — `start_period` generous + # enough that failing checks during that whole window never count + # against `retries` (which would otherwise flip this container to + # "unhealthy" mid-training, blocking `app`'s own + # `depends_on: condition: service_healthy` indefinitely). + start_period: 600s # Deliberately its own image, not built into `app`'s (see # services/tech-step-llm-worker/Dockerfile's own doc comment) — a diff --git a/services/tech-step-intent-service/README.md b/services/tech-step-intent-service/README.md index b501bc6..9dfed69 100644 --- a/services/tech-step-intent-service/README.md +++ b/services/tech-step-intent-service/README.md @@ -19,18 +19,29 @@ ce service en l'état) de pouvoir aussi absorber ce que fait aujourd'hui passer (les modèles `md`, avec vecteurs de mots, sont conservés dans ce but, même si rien ici ne s'en sert encore). -## Pourquoi ce service ne possède aucune donnée d'entraînement +## Ce service est entièrement autonome -Contrairement à un service NLP habituel, **ce service ne connaît aucune -technique par lui-même** — `apps/api` reste l'unique source de vérité du -corpus (`TECH_STEP_TRAINING_DATA`, -`apps/api/src/lib/recipe-matching/tech-step-training-data.ts`, revu par PR -comme le reste du code). Il pousse l'intégralité du corpus ici via -`POST /v1/train` à chaque warm-up serveur (`TechStepClassifierService._train`) -— ce service (re)construit alors son pipeline en mémoire, sans jamais rien -persister sur disque. Le workflow mainteneur existant -(`apps/api/src/scripts/retrain-tech-steps.ts`, édition manuelle du corpus) -n'a pas changé. +Contrairement à sa toute première version, **ce service possède désormais +son propre corpus** — `intent_service/training_data.py` +(`TECH_STEP_TRAINING_DATA`), revu par PR comme le reste du code. Il +s'entraîne lui-même une seule fois, à son propre démarrage +(`PipelineRegistry.initialize()`, appelé par `main.py`'s `lifespan`), et ne +persiste jamais rien sur disque — un redémarrage du process réentraîne +toujours from scratch depuis ce fichier. `apps/api` ne connaît plus aucune +technique ni aucun synonyme : il n'appelle plus que `POST /v1/process` (plus +de `POST /v1/train`, supprimé). + +Workflow mainteneur pour changer le corpus : + +1. Éditer `intent_service/training_data.py` à la main (informé par le + rapport de `apps/api/src/scripts/list-pending-training-suggestions.ts`). +2. **Redémarrer ce service** (`docker compose restart tech-step-intent-service`, + ou simplement redéployer) — le nouveau corpus n'a d'effet qu'une fois + réentraîné au démarrage, contrairement à l'ancienne version qui pouvait + être réentraînée à chaud via `POST /v1/train`. +3. Depuis `apps/api`, lancer `pnpm --filter api exec tsx + src/scripts/retrain-tech-steps.ts` — vérifie le F1 contre + `TECH_STEP_EVAL_DATASET` avant de backfiller les recettes existantes. ## Pourquoi ce service vit hors du workspace pnpm @@ -39,31 +50,48 @@ n'a rien à faire dans `pnpm-workspace.yaml` (qui ne couvre que `apps/*`/`packages/*`), et ses dépendances (spaCy, ses modèles) ne doivent jamais se retrouver dans l'image `apps/api`. **Aucun accès direct à Postgres** non plus — la résolution `TechStep.key -> id` reste entièrement -côté `apps/api` (`TechStepClassifierService._train`), ce service ne -manipule que des `uid` (chaînes opaques) tout du long. +côté `apps/api` (`TechStepClassifierService`), ce service ne manipule que +des `uid` (chaînes opaques) tout du long. ## Contrat HTTP Voir `intent_service/schemas.py` pour le détail exact. En résumé : -- `GET /health` — sans authentification, `200` une fois les modèles spaCy - de base chargés (pas de lazy-load, voir `intent_service/main.py`). -- `POST /v1/train` — `{ locale, entries: [{ uid, synonyms, utterances }] }` - → reconstruit le pipeline de `locale` à neuf. +- `GET /health` — sans authentification, `200` une fois ce service + entièrement prêt : modèles spaCy de base chargés **et** les deux locales + entraînées (pas de lazy-load, voir `intent_service/main.py`) — voir + "Temps de démarrage" plus bas pour ce que ça implique en pratique. - `POST /v1/process` — `{ locale, text }` → `{ entities: [{ uid, start, end }], intent, score }`. -`/v1/train` et `/v1/process` exigent le header `X-Intent-Service-Secret` -(voir `intent_service/security.py`), qui doit matcher `INTENT_SERVICE_SECRET` +`/v1/process` exige le header `X-Intent-Service-Secret` (voir +`intent_service/security.py`), qui doit matcher `INTENT_SERVICE_SECRET` côté `apps/api`. +## Temps de démarrage + +**Ce service met plusieurs minutes à devenir `healthy`** — contrairement à +node-nlp (entraînement quasi instantané), entraîner le `textcat` sur le +corpus réel (~74 techniques) prend de l'ordre de 200 secondes par locale +(mesuré localement, sans GPU), donc environ 400 secondes (~7 minutes) pour +`fr`+`en` combinés à chaque démarrage du process. `docker-compose.yml` et +`.github/workflows/ci.yml` ont un `start_period`/timeout d'attente +généreux pour ça — voir leurs propres commentaires. C'est un compromis +assumé, pas un défaut de configuration à corriger : moins d'itérations +entraîne plus vite mais laisse des verdicts corrects sous +`CONFIDENCE_THRESHOLD` (voir le commentaire de cette constante, +`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`, et celui de +`_TRAINING_ITERATIONS`/`_TRAINING_BATCH_SIZE` dans `locale_pipeline.py` +pour le détail du compromis). + ## Logs `intent_service/logging_config.py` branche un format JSON structuré (une ligne par évènement — `timestamp`/`level`/`message` + champs métier fusionnés — même convention que `LoggerService` côté `apps/api`) sur toute la journalisation de ce service, niveau `LOG_LEVEL` (`INFO` par défaut, voir -`.env.example`). `routes/process.py` et `routes/train.py` journalisent -chaque appel avec son input et son output complets : +`.env.example`). `routes/process.py` journalise chaque appel avec son input +et son output complets, `pipeline_registry.py` journalise le déroulement de +l'entraînement au démarrage : ```json {"timestamp": "...", "level": "info", "message": "tech-step NLP process", "locale": "fr", "text": "faire fondre le beurre", "entities": [{"uid": "melt", "start": 6, "end": 13}], "intent": "melt", "score": 0.93} @@ -95,7 +123,8 @@ uv run uvicorn intent_service.main:app --reload --port 8000 aux côtés de `postgres`/`app`/`tech-step-llm-worker` — **pas optionnel**, contrairement au worker LLM : sans lui, `apps/api` ne peut plus détecter aucune technique de cuisine. `app` attend qu'il soit `healthy` -(`depends_on: condition: service_healthy`) avant de démarrer. +(`depends_on: condition: service_healthy`) avant de démarrer — voir "Temps +de démarrage" ci-dessus pour combien de temps ça prend en pratique. ## Testing @@ -107,6 +136,10 @@ uv run pytest exacts et d'insensibilité accents/casse de `apps/api/test/recipe-matching/tech-step-matcher.test.ts` — le point de fidélité le plus critique de ce service (voir le plan de migration). +`tests/conftest.py`'s fixture `client` (scope "session") ne s'entraîne +qu'une seule fois pour toute la suite — c'est *le vrai corpus complet*, +pas un jeu jouet, donc la première utilisation de cette fixture prend le +même temps qu'un vrai démarrage (voir "Temps de démarrage" ci-dessus). Aucun test ici ne dépend d'une vraie base Postgres ni d'`apps/api` en service — à l'inverse, la suite Mocha d'`apps/api` @@ -114,24 +147,24 @@ service — à l'inverse, la suite Mocha d'`apps/api` vraie instance de ce service tournant (voir `apps/api/.env.test`), conforme à la convention du repo de ne jamais mocker un service interne. -## Limitations connues (première version) +## Limitations connues -- **`/v1/train` prend de l'ordre de la minute par locale** (~110s mesuré en - CI avec `_TRAINING_ITERATIONS`/`_TRAINING_BATCH_SIZE` actuels, voir - `locale_pipeline.py`) — `apps/api` l'appelle deux fois au warm-up - (`fr`/`en`), donc un redémarrage prend quelques minutes avant qu'une - recette puisse voir ses techniques détectées. Contrairement à node-nlp - (entraînement quasi instantané), c'est un vrai compromis assumé : moins - d'itérations entraînait plus vite mais laissait des verdicts corrects - sous `CONFIDENCE_THRESHOLD` (voir le commentaire de cette constante, - `apps/api/src/lib/recipe-matching/tech-step-matcher.ts`). +- **Démarrage lent** (~7 minutes) — voir "Temps de démarrage" ci-dessus. + Une optimisation possible non explorée : parallélisation de + l'entraînement `fr`/`en` (actuellement séquentiel, + `PipelineRegistry.initialize`). +- **`CONFIDENCE_THRESHOLD` côté `apps/api` est un placeholder** depuis + l'élargissement du corpus à ~74 techniques (calibré à la main, pas via + une vraie repasse de `calibrate-tech-step-threshold.ts` contre + `TECH_STEP_EVAL_DATASET` — voir le commentaire de cette constante). - **Textcat bag-of-words** (`spacy.TextCatBOW.v3`) — suffisant pour le corpus actuel une fois correctement entraîné, mais n'exploite pas les vecteurs de mots des modèles `md` chargés. Migrable vers une architecture tok2vec/similarité sans changer le contrat HTTP, si le F1 mesuré par `apps/api/src/scripts/calibrate-tech-step-threshold.ts` le justifie un jour. -- **Reconstruit tout le pipeline à chaque `/v1/train`** (pas de fusion - incrémentale) — un choix délibéré (voir `LocalePipeline.train`), pas une - limitation à lever : `TECH_STEP_TRAINING_DATA` doit toujours rester - l'unique source de vérité, jamais un état local qui dérive. +- **Reconstruit tout le pipeline à chaque démarrage** (pas de persistance, + pas de fusion incrémentale) — un choix délibéré (voir + `LocalePipeline.train`), pas une limitation à lever : `training_data.py` + doit toujours rester l'unique source de vérité, jamais un état sur disque + qui pourrait dériver. diff --git a/services/tech-step-intent-service/intent_service/config.py b/services/tech-step-intent-service/intent_service/config.py index 4e2c492..16427a8 100644 --- a/services/tech-step-intent-service/intent_service/config.py +++ b/services/tech-step-intent-service/intent_service/config.py @@ -42,8 +42,9 @@ class Settings(BaseSettings): # Niveau du logging structuré (`logging_config.py`) — voir ce module pour # le format. `INFO` par défaut : c'est à ce niveau que `routes/process.py` - # et `routes/train.py` journalisent chaque input/output du pipeline NLP, - # pour qu'un déploiement par défaut les voie sans configuration + # journalise chaque input/output du pipeline NLP, et que + # `pipeline_registry.py` journalise l'entraînement au démarrage, pour + # qu'un déploiement par défaut les voie sans configuration # supplémentaire (`docker logs`/Portainer). log_level: str = "INFO" diff --git a/services/tech-step-intent-service/intent_service/locale_pipeline.py b/services/tech-step-intent-service/intent_service/locale_pipeline.py index a024839..b55a74c 100644 --- a/services/tech-step-intent-service/intent_service/locale_pipeline.py +++ b/services/tech-step-intent-service/intent_service/locale_pipeline.py @@ -2,8 +2,9 @@ `node-nlp`'s `NlpManager` faisait pour cette locale dans `TechStepClassifierService` (`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`) : NER par entités enum (ici un `PhraseMatcher`) + classification d'intention -(ici un `textcat`), les deux entraînés à partir du même corpus -(`TECH_STEP_TRAINING_DATA`, poussé par `apps/api` via `POST /v1/train`). +(ici un `textcat`), les deux entraînés à partir du corpus possédé par ce +service lui-même (`training_data.TECH_STEP_TRAINING_DATA` — plus poussé par +`apps/api` via HTTP, voir `pipeline_registry.py`). Le modèle de base spaCy (tokenizer + vecteurs + le composant `diacritics_normalizer` défini plus bas) est chargé une seule fois @@ -12,9 +13,8 @@ paresseusement au premier `train()`, pour que `GET /health` ne devienne `200` qu'une fois ce coût payé) puis réutilisé à chaque `train()` : seul le `textcat` (retiré puis rajouté à neuf) et le `PhraseMatcher` (remplacé) sont reconstruits à chaque appel, jamais le tokenizer/les vecteurs. Rien n'est -jamais persisté sur disque — même posture que `autoSave`/`autoLoad: false` -sur l'ancien `NlpManager` : `TECH_STEP_TRAINING_DATA` (côté `apps/api`) reste -l'unique source de vérité. +jamais persisté sur disque — `training_data.py` reste l'unique source de +vérité, reconstruite en mémoire depuis zéro à chaque démarrage du process. """ from __future__ import annotations @@ -50,21 +50,32 @@ _EXCLUDED_COMPONENTS = ["parser", "ner", "tagger", "morphologizer", "attribute_r _TEXTCAT_PIPE_NAME = "textcat" # Nombre d'itérations d'entraînement du textcat et taille de minibatch — -# calibrés empiriquement contre le corpus réel (`TECH_STEP_TRAINING_DATA`, -# ~26 techniques/locale), pas seulement contre les petits corpus jouets des -# tests de ce fichier. Une première valeur plus basse (30 itérations, lot de -# 8) convergeait mal sur le vrai corpus : des clauses correctement -# classifiées mais sans ancre NER (le cas motivant tout ce pipeline, voir -# `TechStepClassifierService`'s doc comment côté apps/api) scoraient à peine -# 0.5-0.7, et des clauses *avec* ancre à peine 0.2-0.3 — bien en dessous de -# `CONFIDENCE_THRESHOLD` (`tech-step-matcher.ts`), un CI réel l'a confirmé -# avant que ces valeurs ne soient relevées. `150`/`16` entraîne en ~110s par -# locale sur un runner GitHub Actions standard (donc ~220s pour fr+en -# combinés au warm-up — voir `server.ts`'s propre commentaire sur le retry -# côté apps/api) et pousse les mêmes scores nettement au-dessus du seuil -# (melt ~0.95, preheat ~0.90, bake ~0.51) sans dégrader le rejet du bruit -# (texte anglais via le classifieur français reste ~0.05, inchangé). -_TRAINING_ITERATIONS = 150 +# calibrés empiriquement contre le corpus réel (`training_data.py`), pas +# seulement contre les petits corpus jouets des tests de ce fichier. Trop +# peu d'itérations laisse des clauses correctement classifiées (bon argmax) +# mais avec une confiance dérisoire (`0.02`-`0.08` observé à 5-15 +# itérations) — bien en dessous de tout seuil raisonnable pour +# `CONFIDENCE_THRESHOLD` (`tech-step-matcher.ts`). +# +# `150` convenait au corpus original (~26 techniques) mais ne passe plus à +# l'échelle une fois le corpus élargi à ~74 : le temps d'entraînement croît +# avec le nombre de classes autant qu'avec les itérations (mesuré : +# ~150s pour seulement 30 itérations sur 74 classes, contre ~110s pour 150 +# itérations sur 26 classes) — `150` sur 74 classes dépassait 17 minutes +# rien que pour une locale, constaté en CI. `40` est le meilleur compromis +# trouvé empiriquement sur ce corpus élargi : ~200s par locale (~400s pour +# fr+en combinés au démarrage), avec des scores exploitables sur tous les +# cas testés à la main (melt ~0.89, preheat ~0.76, compote ~0.76, zest +# ~0.64, julienne ~0.56, cook/bake ~0.33, simmer ~0.25 — le plus faible +# observé, toujours correct en argmax et de toute façon ancré par NER) et +# un bruit hors-vocabulaire qui reste négligeable (anglais via le +# classifieur français : `~0.02`). Une vraie repasse de +# `calibrate-tech-step-threshold.ts` contre `TECH_STEP_EVAL_DATASET` reste +# nécessaire pour confirmer/affiner ces deux valeurs (voir +# `CONFIDENCE_THRESHOLD`'s propre commentaire, `tech-step-matcher.ts`) — ce +# qui suit est une mesure manuelle ponctuelle, pas un remplacement de cette +# calibration. +_TRAINING_ITERATIONS = 40 _TRAINING_BATCH_SIZE = 16 # Abaissé de `0.2` avec le reste de cette recalibration — `0.1` régularise # encore contre la petite taille du corpus par technique tout en laissant @@ -107,8 +118,8 @@ class _DiacriticsNormalizer: @dataclass class TrainEntry: - """Une technique à entraîner pour une locale — miroir de - `TrainEntryPayload` (`schemas.py`).""" + """Une technique à entraîner pour une locale — construit par + `PipelineRegistry.initialize()` depuis `training_data.entries_for_locale`.""" uid: str synonyms: list[str] = field(default_factory=list) diff --git a/services/tech-step-intent-service/intent_service/logging_config.py b/services/tech-step-intent-service/intent_service/logging_config.py index 9019662..6c59dde 100644 --- a/services/tech-step-intent-service/intent_service/logging_config.py +++ b/services/tech-step-intent-service/intent_service/logging_config.py @@ -6,7 +6,7 @@ Portainer ou un agrégateur de logs — cohérent avec le reste du repo plutôt qu'un format propre à ce seul service. Configuré une fois au démarrage (`main.py`) plutôt que par un `print()` ad -hoc dans chaque route — `routes/process.py`/`routes/train.py` appellent +hoc dans chaque route — `routes/process.py`/`pipeline_registry.py` appellent `logging.getLogger(__name__)` normalement, ce module ne fait que brancher le formateur JSON sur la racine du logging Python. """ @@ -69,7 +69,8 @@ def configure_logging(level: str) -> None: # spaCy/thinc journalisent leur propre chatter interne ("Created # vocabulary", "Finished initializing nlp object"...) sur le logger # `"spacy"`, qui propage jusqu'à la racine et se retrouverait donc - # mélangé aux lignes input/output de `routes/process.py`/`routes/train.py` + # mélangé aux lignes input/output de `routes/process.py`/l'entraînement + # journalisé par `pipeline_registry.py` # — ce sont ces dernières que ce service existe pour rendre visibles, pas # le détail interne de spaCy. `WARNING` laisse quand même remonter un # vrai problème (dépréciation, échec partiel) sans le bruit `INFO`. diff --git a/services/tech-step-intent-service/intent_service/main.py b/services/tech-step-intent-service/intent_service/main.py index 9f8d95d..e6af2c9 100644 --- a/services/tech-step-intent-service/intent_service/main.py +++ b/services/tech-step-intent-service/intent_service/main.py @@ -1,11 +1,15 @@ """Point d'entrée FastAPI — `uv run uvicorn intent_service.main:app` (voir le Dockerfile et le README de ce service). -Le chargement des modèles spaCy de base (`PipelineRegistry.preload_all`) se -fait dans le handler `lifespan` ci-dessous, *avant* qu'uvicorn n'accepte de -requêtes — `GET /health` ne répond donc `200` qu'une fois ce coût payé, -jamais pendant un chargement encore en cours (uvicorn ne sert aucune requête -tant que le `lifespan` de démarrage n'est pas terminé). +Le chargement des modèles spaCy de base *et* l'entraînement de chaque +locale (`PipelineRegistry.initialize`) se font dans le handler `lifespan` +ci-dessous, *avant* qu'uvicorn n'accepte de requêtes — `GET /health` ne +répond donc `200` qu'une fois ce coût payé (chargement + entraînement), +jamais pendant qu'il est encore en cours (uvicorn ne sert aucune requête +tant que le `lifespan` de démarrage n'est pas terminé). Ce service est +autonome : `training_data.TECH_STEP_TRAINING_DATA` vit dans ce module, +`apps/api` ne pousse plus rien via HTTP (voir `pipeline_registry.py` pour +le détail de ce que ça change par rapport à la version précédente). """ from contextlib import asynccontextmanager @@ -15,22 +19,21 @@ from fastapi import FastAPI from .config import settings from .logging_config import configure_logging from .pipeline_registry import registry -from .routes import health, process, train +from .routes import health, process -# Avant tout le reste : `routes/process.py`/`routes/train.py` journalisent -# dès la première requête, `preload_all()` ci-dessous journalise aussi (voir +# Avant tout le reste : `routes/process.py` journalise dès la première +# requête, `initialize()` ci-dessous journalise aussi (voir # `pipeline_registry.py`) — le formateur JSON doit déjà être en place. configure_logging(settings.log_level) @asynccontextmanager async def lifespan(app: FastAPI): - registry.preload_all() + registry.initialize() yield app = FastAPI(title="tech-step-intent-service", lifespan=lifespan) app.include_router(health.router) -app.include_router(train.router) app.include_router(process.router) diff --git a/services/tech-step-intent-service/intent_service/pipeline_registry.py b/services/tech-step-intent-service/intent_service/pipeline_registry.py index 1f35dfe..de7d435 100644 --- a/services/tech-step-intent-service/intent_service/pipeline_registry.py +++ b/services/tech-step-intent-service/intent_service/pipeline_registry.py @@ -3,16 +3,17 @@ partagé du process (une instance vit pour toute la durée de vie d'`uvicorn`, montée sur `app.state`, voir `main.py`). Volontairement une classe "registre" séparée de `LocalePipeline` lui-même : -`LocalePipeline` ne connaît qu'une seule locale, ce module route -`train`/`process` vers la bonne instance selon le `locale` reçu dans la -requête — même séparation de responsabilité que `TechStepClassifierService` -(une seule instance, un seul `NlpManager` multi-langues) avait implicitement -via node-nlp, explicitée ici puisque spaCy charge un modèle par langue. +`LocalePipeline` ne connaît qu'une seule locale, ce module route `process` +vers la bonne instance selon le `locale` reçu dans la requête — même +séparation de responsabilité que `TechStepClassifierService` (une seule +instance, un seul `NlpManager` multi-langues) avait implicitement via +node-nlp, explicitée ici puisque spaCy charge un modèle par langue. """ import logging -from .locale_pipeline import SUPPORTED_LOCALES, LocalePipeline, ProcessResult, TrainEntry, UnsupportedLocaleError +from .locale_pipeline import SUPPORTED_LOCALES, LocalePipeline, ProcessResult, TrainEntry +from .training_data import entries_for_locale logger = logging.getLogger(__name__) @@ -23,21 +24,38 @@ class PipelineRegistry: locale: LocalePipeline(locale) for locale in SUPPORTED_LOCALES } - def preload_all(self) -> None: - """Charge le modèle spaCy de base de chaque locale connue — appelé - une fois au démarrage du process (`main.py`), pas paresseusement au - premier appel, pour que `GET /health` ne réponde `200` qu'une fois - ce coût payé (voir `LocalePipeline.preload`).""" - logger.info("tech-step NLP preloading base pipelines", extra={"locales": list(self._pipelines)}) - for pipeline in self._pipelines.values(): - pipeline.preload() - logger.info("tech-step NLP base pipelines ready", extra={"locales": list(self._pipelines)}) + def initialize(self) -> None: + """Charge le modèle spaCy de base *et* entraîne chaque locale connue + depuis `training_data.TECH_STEP_TRAINING_DATA` — appelé une fois au + démarrage du process (`main.py`'s `lifespan`), avant que `uvicorn` + n'accepte de requêtes. - def train(self, locale: str, entries: list[TrainEntry]) -> tuple[int, int, int]: - pipeline = self._pipelines.get(locale) - if pipeline is None: - raise UnsupportedLocaleError(f"Unsupported locale: {locale!r}") - return pipeline.train(entries) + Contrairement à la version précédente de ce service (où `apps/api` + poussait le corpus via `POST /v1/train` à son propre warm-up), ce + service est maintenant entièrement autonome : `apps/api` ne connaît + plus aucune technique, seulement le résultat de + `POST /v1/process`. `GET /health` ne répond `200` qu'une fois cette + méthode terminée (chargement *et* entraînement) — pas seulement le + chargement — pour que `docker-compose.yml`'s `depends_on: ... + condition: service_healthy` (et la boucle d'attente équivalente en + CI) ne laisse jamais `apps/api` démarrer face à un service qui + répondrait mais ne saurait encore rien détecter. + """ + logger.info("tech-step NLP initializing pipelines", extra={"locales": list(self._pipelines)}) + for locale, pipeline in self._pipelines.items(): + pipeline.preload() + entries = [TrainEntry(**entry) for entry in entries_for_locale(locale)] + label_count, utterance_count, synonym_count = pipeline.train(entries) + logger.info( + "tech-step NLP pipeline trained", + extra={ + "locale": locale, + "labelCount": label_count, + "utteranceCount": utterance_count, + "synonymCount": synonym_count, + }, + ) + logger.info("tech-step NLP pipelines ready", extra={"locales": list(self._pipelines)}) def process(self, locale: str, text: str) -> ProcessResult: pipeline = self._pipelines.get(locale) diff --git a/services/tech-step-intent-service/intent_service/routes/train.py b/services/tech-step-intent-service/intent_service/routes/train.py deleted file mode 100644 index cd0795c..0000000 --- a/services/tech-step-intent-service/intent_service/routes/train.py +++ /dev/null @@ -1,58 +0,0 @@ -"""`POST /v1/train` — appelé par `apps/api` (`IntentServiceClient.train`, -`TechStepClassifierService._train`) une fois par locale à chaque warm-up -serveur, avec l'intégralité de `TECH_STEP_TRAINING_DATA` filtrée pour cette -locale. Voir `LocalePipeline.train` pour ce que "reconstruit à neuf" signifie -concrètement. -""" - -import logging - -from fastapi import APIRouter, Depends, HTTPException, status - -from ..locale_pipeline import TrainEntry, UnsupportedLocaleError -from ..pipeline_registry import registry -from ..schemas import TrainRequest, TrainResponse -from ..security import require_valid_secret - -logger = logging.getLogger(__name__) - -router = APIRouter(dependencies=[Depends(require_valid_secret)]) - - -@router.post("/v1/train", response_model=TrainResponse) -def train(request: TrainRequest) -> TrainResponse: - entries = [ - TrainEntry(uid=entry.uid, synonyms=entry.synonyms, utterances=entry.utterances) - for entry in request.entries - ] - - logger.info( - "tech-step NLP train starting", - extra={"locale": request.locale, "uids": [entry.uid for entry in entries]}, - ) - try: - label_count, utterance_count, synonym_count = registry.train(request.locale, entries) - except UnsupportedLocaleError as err: - logger.warning("tech-step NLP train rejected", extra={"locale": request.locale, "error": str(err)}) - # 422, pas 500 : une locale non supportée dans une requête de - # `apps/api` est une erreur de configuration/version-skew entre les - # deux services (voir le contrat documenté dans le plan de - # migration), pas un échec inattendu du service lui-même. - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(err)) from err - - logger.info( - "tech-step NLP train done", - extra={ - "locale": request.locale, - "labelCount": label_count, - "utteranceCount": utterance_count, - "synonymCount": synonym_count, - }, - ) - - return TrainResponse( - locale=request.locale, - label_count=label_count, - utterance_count=utterance_count, - synonym_count=synonym_count, - ) diff --git a/services/tech-step-intent-service/intent_service/schemas.py b/services/tech-step-intent-service/intent_service/schemas.py index 51d1c91..ffafdd1 100644 --- a/services/tech-step-intent-service/intent_service/schemas.py +++ b/services/tech-step-intent-service/intent_service/schemas.py @@ -1,43 +1,13 @@ """Modèles Pydantic du contrat HTTP — voir le plan de migration pour le contrat exact attendu côté `apps/api` (`IntentServiceClient`, `apps/api/src/lib/recipe-matching/intent-service-client.ts`). + +Pas de `POST /v1/train` ici — ce service s'entraîne lui-même au démarrage +depuis `training_data.py` (voir `pipeline_registry.py`/`main.py`), plus +besoin d'un contrat HTTP pour ça. """ -from pydantic import BaseModel, ConfigDict, Field -from pydantic.alias_generators import to_camel - -# --------------------------------------------------------------------------- -# POST /v1/train -# --------------------------------------------------------------------------- - - -class TrainEntryPayload(BaseModel): - """Une technique — mêmes champs qu'une entrée de `TECH_STEP_TRAINING_DATA` - (`apps/api/src/lib/recipe-matching/tech-step-training-data.ts`) pour une - locale donnée.""" - - uid: str - synonyms: list[str] = Field(default_factory=list) - utterances: list[str] = Field(default_factory=list) - - -class TrainRequest(BaseModel): - locale: str - entries: list[TrainEntryPayload] - - -class TrainResponse(BaseModel): - # camelCase en sortie (`labelCount`, pas `label_count`) — cohérent avec - # la convention JSON déjà en place côté `apps/api`/`packages/shared` - # (voir `TechStepAuditClauseView` etc.), même si le code Python interne - # reste en snake_case (convention PEP 8). - model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) - - locale: str - label_count: int - utterance_count: int - synonym_count: int - +from pydantic import BaseModel # --------------------------------------------------------------------------- # POST /v1/process diff --git a/services/tech-step-intent-service/intent_service/training_data.py b/services/tech-step-intent-service/intent_service/training_data.py new file mode 100644 index 0000000..535c4fa --- /dev/null +++ b/services/tech-step-intent-service/intent_service/training_data.py @@ -0,0 +1,1692 @@ +"""Corpus d'entraînement pour {@link LocalePipeline} — anciennement possédé +par `apps/api` (`tech-step-training-data.ts`, poussé via `POST /v1/train` à +chaque warm-up serveur), rapatrié ici pour que ce service soit entièrement +autonome : il s'entraîne lui-même une seule fois au démarrage +(`pipeline_registry.py`'s `initialize()`, appelé par `main.py`'s +`lifespan`), sans dépendre d'un appel HTTP externe. `apps/api` ne connaît +plus aucune technique ni aucun synonyme — seul `TechStep.key -> id` +(`reference-seed-data.ts`) doit encore rester en phase avec les `uid` ici : +chaque `uid` ci-dessous doit avoir une entrée `TECH_STEPS` correspondante, +sans quoi `TechStepClassifierService` résout un match qu'il ne peut +persister (voir son propre commentaire sur ce cas). + +Deux types de contenu par technique/locale, comme avant la migration +Python : + +- `synonyms` — mots/phrases courtes alimentant le `PhraseMatcher` (NER par + énumération) — les mentions *candidates* d'une technique, avant tout + jugement de sens. +- `utterances` — phrases d'exemple complètes alimentant le `textcat` + (classification d'intention) — mélange volontaire de tournures ancrées + sur le mot-clé et de paraphrases qui ne l'emploient jamais, pour que le + classifieur apprenne à reconnaître le *sens*, pas seulement le mot. + +Les 26 premières entrées (jusqu'à `coat`) sont le corpus original, +directement porté depuis `tech-step-training-data.ts` (voir l'historique +Git de ce fichier côté `apps/api` pour le détail des régressions qui ont +façonné chaque liste). Les entrées suivantes ajoutent le lexique de +techniques fourni par l'utilisateur — un synonyme volontairement en phrase +complète plutôt qu'au mot nu quand une forme courte collisionnerait avec +une technique existante (ex. `whiskPale`/"blanchir un jaune d'œuf" à côté +de `blanch`/"blanchir un légume" — le même verbe français, deux sens +distincts ; `filter_spans` dans `locale_pipeline.py` retient alors la +phrase la plus longue et donc la plus spécifique quand les deux se +chevauchent). +""" + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class TechStepLocaleTrainingData: + synonyms: list[str] = field(default_factory=list) + utterances: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class TechStepTrainingEntry: + """`uid` doit correspondre à un `TechStep.key` (`reference-seed-data.ts` + côté `apps/api`, et à un libellé dans `apps/web`'s + `locales/fr/translation.json`'s `catalog.techSteps.`).""" + + uid: str + fr: TechStepLocaleTrainingData + en: TechStepLocaleTrainingData + + +TECH_STEP_TRAINING_DATA: list[TechStepTrainingEntry] = [ + # ------------------------------------------------------------------ + # Corpus original (26 techniques) — porté depuis tech-step-training-data.ts + # ------------------------------------------------------------------ + TechStepTrainingEntry( + uid="cook", + fr=TechStepLocaleTrainingData( + 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", + "baisser le feu et laisser cuire à découvert encore un quart d'heure", + "faire cuire à feu doux en remuant de temps en temps", + ], + ), + en=TechStepLocaleTrainingData( + # NOT "cooked through"/"cooking through" — extensions de "cooked"/ + # "cooking" créant des candidats NER chevauchants (voir la note de + # `locale_pipeline.py` sur `filter_spans` : plus nécessaire de les + # bannir pour cette raison précise, mais gardé simple). + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="fry", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="melt", + fr=TechStepLocaleTrainingData( + synonyms=[ + "fondre", "fondu", "fondue", "fondues", "faire fondre", "faites fondre", + "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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="deglaze", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="simmer", + fr=TechStepLocaleTrainingData( + synonyms=[ + "mijoter", "mijotez", "mijote", "mijotant", "mijoté", "frémir", "frémissant", + "frémissante", "à petit feu", + # "Mitonner" (lexique ajouté) — synonyme de mijoter, pas une + # technique distincte : sa propre définition le dit + # explicitement ("le laisser mijoter pour en décupler les + # saveurs"). + "mitonner", "mitonnez", "mitonné", "mitonnée", + ], + 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", + "mitonner le plat avec soin à feu très doux pour développer les saveurs", + ], + ), + en=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="boil", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="roast", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="grill", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="panFry", + fr=TechStepLocaleTrainingData( + # Deliberately pas "poêlé"/"poêlée"/"poêlés" : trop proche du nom + # commun "poêle" (voir tech-step-matcher.ts côté apps/api pour le + # faux positif que ça causait avec node-nlp — moins critique avec + # le `PhraseMatcher` exact de ce service, mais gardé par prudence). + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="blanch", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="marinate", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="chop", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="peel", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="mince", + fr=TechStepLocaleTrainingData( + 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", + "émincer les tomates en fines rondelles", + ], + ), + en=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="mix", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="whisk", + fr=TechStepLocaleTrainingData( + 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", + "fouetter les blancs en neige", + "fouetter les blancs en neige jusqu'à ce qu'ils soient fermes", + ], + ), + en=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="foldIn", + fr=TechStepLocaleTrainingData( + synonyms=[ + "incorporer", "incorporez", "incorporé", "incorporée", "incorporées", + "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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="setAside", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="season", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="drain", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="brown", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="rest", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="preheat", + fr=TechStepLocaleTrainingData( + synonyms=[ + "préchauffer", "préchauffez", "préchauffé", "préchauffée", "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", + "préchauffer la poêle avant d'y verser l'huile", + "faire chauffer la poêle à vide quelques minutes", + "mettre la poêle vide sur feu vif avant d'ajouter quoi que ce soit", + "mettre la poêle sur feu vif", + ], + ), + en=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="bake", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="plate", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + TechStepTrainingEntry( + uid="coat", + fr=TechStepLocaleTrainingData( + 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=TechStepLocaleTrainingData( + 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", + ], + ), + ), + # ------------------------------------------------------------------ + # Lexique ajouté (48 nouvelles techniques) + # ------------------------------------------------------------------ + TechStepTrainingEntry( + uid="baste", + fr=TechStepLocaleTrainingData( + synonyms=["arroser", "arrosez", "arrosé", "arrosée", "arrosées", "arrosage"], + utterances=[ + "arroser la volaille avec son jus de cuisson", + "arroser régulièrement le rôti pendant la cuisson", + "verser le jus de cuisson sur la viande toutes les dix minutes", + "napper la pièce de viande avec le beurre fondu pendant qu'elle cuit", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["baste", "bastes", "basted", "basting"], + utterances=[ + "baste the poultry with its cooking juices", + "baste the roast regularly while it cooks", + "spoon the pan juices over the meat every ten minutes", + "brush the meat with melted butter while it cooks", + ], + ), + ), + TechStepTrainingEntry( + uid="appertize", + fr=TechStepLocaleTrainingData( + synonyms=[ + "appertiser", "appertisez", "appertisé", "appertisée", "appertisation", + "stériliser", "stérilisez", "stérilisé", "mise en conserve", + ], + utterances=[ + "stériliser les bocaux avant de les fermer hermétiquement", + "appertiser les légumes pour les conserver plusieurs mois", + "faire chauffer les conserves fermées pour les stériliser", + "mettre en conserve dans des bocaux hermétiques après stérilisation", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["can", "canned", "canning", "sterilize the jars", "appertize", "appertization"], + utterances=[ + "sterilize the jars before sealing them", + "can the vegetables to preserve them for months", + "heat the sealed jars to sterilize them", + "preserve in airtight jars after sterilizing", + ], + ), + ), + TechStepTrainingEntry( + uid="whiskPale", + fr=TechStepLocaleTrainingData( + # Volontairement des phrases, pas le mot nu "blanchir" — sinon + # collision directe avec `blanch` ("blanchir un légume"), un + # homonyme sans rapport. `filter_spans` retient la phrase la plus + # longue quand les deux se chevauchent (voir la note de tête de + # fichier). + synonyms=[ + "blanchir les jaunes", "blanchir le jaune d'œuf", "blanchir les jaunes avec le sucre", + "faire blanchir les œufs et le sucre", "fouetter les jaunes jusqu'à blanchiment", + ], + utterances=[ + "blanchir les jaunes d'œufs avec le sucre jusqu'à ce que le mélange épaississe", + "fouetter énergiquement les jaunes et le sucre jusqu'à ce que la préparation blanchisse", + "battre le mélange jusqu'à ce qu'il devienne mousseux et clair", + "travailler les jaunes et le sucre au fouet jusqu'à obtenir un ruban", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["whisk until pale", "whisk the yolks and sugar", "beat until pale and fluffy", "ribbon stage"], + utterances=[ + "whisk the egg yolks with the sugar until the mixture turns pale", + "beat vigorously until the mixture becomes light and fluffy", + "whip until foamy and pale in color", + "work the yolks and sugar with a whisk until it reaches the ribbon stage", + ], + ), + ), + TechStepTrainingEntry( + uid="goldenBrown", + fr=TechStepLocaleTrainingData( + synonyms=["blondir", "blondissez", "blondi", "blondie", "faire blondir", "légèrement doré"], + utterances=[ + "faire blondir les oignons dans le beurre", + "laisser légèrement dorer sans colorer fortement", + "cuire doucement jusqu'à ce que ce soit juste doré", + "faire blondir le roux avant d'ajouter le liquide", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["cook until golden", "lightly brown", "blonde the onions", "until golden but not browned"], + utterances=[ + "cook the onions until lightly golden", + "let it turn golden without browning too much", + "cook gently until just golden", + "cook the roux until lightly golden before adding the liquid", + ], + ), + ), + TechStepTrainingEntry( + uid="braise", + fr=TechStepLocaleTrainingData( + synonyms=["braiser", "braisez", "braisé", "braisée", "braisées", "à l'étuvée en cocotte"], + utterances=[ + "braiser la viande à couvert pendant deux heures", + "laisser mijoter dans une cocotte fermée avec un fond de sauce", + "cuire à feu doux et à couvert dans une cocotte épaisse", + "faire cuire lentement dans son jus dans une cocotte fermée", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["braise", "braises", "braised", "braising"], + utterances=[ + "braise the meat covered for two hours", + "let it simmer in a closed pot with a little sauce", + "cook slowly, covered, in a heavy pot", + "cook it slowly in its own juices in a covered pot", + ], + ), + ), + TechStepTrainingEntry( + uid="truss", + fr=TechStepLocaleTrainingData( + synonyms=["brider", "bridez", "bridé", "bridée", "bridées", "bridage", "ficeler la volaille"], + utterances=[ + "brider la volaille avant de l'enfourner", + "ficeler les pattes et les ailes pour maintenir la forme", + "attacher la volaille avec de la ficelle de cuisine", + "maintenir les membres avec de la ficelle avant cuisson", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["truss", "trusses", "trussed", "trussing", "tie up the poultry"], + utterances=[ + "truss the poultry before putting it in the oven", + "tie the legs and wings to keep its shape", + "tie up the bird with kitchen twine", + "secure the limbs with string before cooking", + ], + ), + ), + TechStepTrainingEntry( + uid="caramelize", + fr=TechStepLocaleTrainingData( + synonyms=["caraméliser", "caramélisez", "caramélisé", "caramélisée", "caramélisées"], + utterances=[ + "caraméliser le sucre à sec dans une casserole", + "laisser les sucs caraméliser au fond de la cocotte", + "faire caraméliser les fruits dans le beurre et le sucre", + "napper le moule de caramel avant d'y verser la préparation", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["caramelize", "caramelizes", "caramelized", "caramelizing"], + utterances=[ + "caramelize the sugar dry in a saucepan", + "let the juices caramelize at the bottom of the pot", + "caramelize the fruit in butter and sugar", + "coat the mold with caramel before pouring in the mixture", + ], + ), + ), + TechStepTrainingEntry( + uid="score", + fr=TechStepLocaleTrainingData( + synonyms=["cerner", "cernez", "cerné", "cernée", "inciser la peau", "entailler légèrement"], + utterances=[ + "cerner la peau du fruit avant de le peler", + "inciser légèrement la peau avec la pointe d'un couteau", + "entailler la peau tout autour pour faciliter l'épluchage", + "marquer la pâte à l'emporte-pièce avant la cuisson", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["score", "scores", "scored", "scoring", "score the skin"], + utterances=[ + "score the skin of the fruit before peeling it", + "lightly cut the skin with the tip of a knife", + "score around the skin to make peeling easier", + "mark the dough with a cutter before baking", + ], + ), + ), + TechStepTrainingEntry( + uid="lineMold", + fr=TechStepLocaleTrainingData( + synonyms=["chemiser", "chemisez", "chemisé", "chemisée", "tapisser le moule"], + utterances=[ + "chemiser le moule avec du papier sulfurisé", + "tapisser le fond du moule de beurre et de farine", + "recouvrir les parois du moule de caramel avant de verser la préparation", + "beurrer et fariner le moule pour faciliter le démoulage", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["line the mold", "line the tin", "grease and flour the mold"], + utterances=[ + "line the mold with parchment paper", + "line the bottom of the mold with butter and flour", + "coat the sides of the mold with caramel before pouring in the mixture", + "butter and flour the mold to make unmolding easier", + ], + ), + ), + TechStepTrainingEntry( + uid="clarify", + fr=TechStepLocaleTrainingData( + synonyms=["clarifier", "clarifiez", "clarifié", "clarifiée", "beurre clarifié"], + utterances=[ + "clarifier le beurre fondu pour retirer le petit-lait", + "filtrer le bouillon pour le débarrasser de ses impuretés", + "séparer le blanc du jaune pour clarifier l'œuf", + "passer le jus au chinois pour obtenir un liquide clair", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["clarify", "clarifies", "clarified", "clarifying", "clarified butter"], + utterances=[ + "clarify the melted butter to remove the milk solids", + "strain the stock to remove any impurities", + "separate the white from the yolk to clarify the egg", + "strain the liquid through a fine sieve until clear", + ], + ), + ), + TechStepTrainingEntry( + uid="compote", + fr=TechStepLocaleTrainingData( + synonyms=["compoter", "compotez", "compoté", "compotée", "cuire en compote"], + utterances=[ + "laisser compoter les fruits à feu très doux", + "cuire longuement à couvert jusqu'à obtenir une texture de compote", + "laisser réduire doucement jusqu'à ce que les fruits s'effondrent", + "mijoter très longtemps à feu doux pour obtenir une marmelade", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["stew down", "compote", "cook down into a compote"], + utterances=[ + "let the fruit stew down over very low heat", + "cook covered for a long time until it reaches a compote texture", + "let it reduce slowly until the fruit breaks down", + "simmer for a long time over low heat until jammy", + ], + ), + ), + TechStepTrainingEntry( + uid="concasse", + fr=TechStepLocaleTrainingData( + synonyms=["concasser", "concassez", "concassé", "concassée", "concassées", "hacher grossièrement"], + utterances=[ + "concasser grossièrement les tomates", + "écraser les fruits secs au couteau", + "broyer grossièrement les épices au pilon", + "hacher très grossièrement les herbes avant de les ajouter", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["coarsely chop", "crush", "concasse", "roughly crush"], + utterances=[ + "coarsely chop the tomatoes", + "crush the nuts with a knife", + "roughly crush the spices with a mortar and pestle", + "very roughly chop the herbs before adding them", + ], + ), + ), + TechStepTrainingEntry( + uid="confit", + fr=TechStepLocaleTrainingData( + synonyms=["confire", "confit", "confite", "confites", "confisez", "cuisson au confit"], + utterances=[ + "faire confire les cuisses de canard dans leur graisse", + "laisser confire longuement à basse température", + "cuire doucement immergé dans la graisse pendant plusieurs heures", + "conserver les fruits en les confisant dans le sucre", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["confit", "confited", "confiting", "cook confit-style"], + utterances=[ + "confit the duck legs in their own fat", + "let it confit slowly at low temperature", + "cook it gently submerged in fat for several hours", + "preserve the fruit by confiting it in sugar", + ], + ), + ), + TechStepTrainingEntry( + uid="julienne", + fr=TechStepLocaleTrainingData( + synonyms=["julienne", "en julienne", "tailler en julienne", "couper en julienne"], + utterances=[ + "couper les carottes en julienne", + "tailler les légumes en fins bâtonnets", + "détailler en julienne avant de faire sauter", + "couper en fines lanières de trois à cinq centimètres", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["julienne", "cut into julienne", "julienne strips"], + utterances=[ + "cut the carrots into julienne", + "cut the vegetables into thin matchsticks", + "julienne the vegetables before stir-frying", + "cut into thin strips about two inches long", + ], + ), + ), + TechStepTrainingEntry( + uid="brunoise", + fr=TechStepLocaleTrainingData( + synonyms=["brunoise", "en brunoise", "tailler en brunoise", "couper en brunoise"], + utterances=[ + "couper les légumes en brunoise", + "tailler en tout petits dés réguliers", + "détailler en minuscules cubes après avoir taillé des tranches fines", + "couper en dés très fins pour la garniture", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["brunoise", "cut into brunoise", "fine dice"], + utterances=[ + "cut the vegetables into brunoise", + "cut into very small even dice", + "dice into tiny cubes after slicing thinly", + "finely dice for the garnish", + ], + ), + ), + TechStepTrainingEntry( + uid="mirepoix", + fr=TechStepLocaleTrainingData( + synonyms=["mirepoix", "en mirepoix", "tailler en mirepoix", "couper en mirepoix"], + utterances=[ + "couper les carottes et les oignons en mirepoix", + "tailler les légumes en gros dés pour le fond de sauce", + "détailler en cubes d'un centimètre pour la garniture aromatique", + "couper en gros dés irréguliers pour parfumer le bouillon", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["mirepoix", "cut into mirepoix", "large dice for a stock"], + utterances=[ + "cut the carrots and onions into mirepoix", + "cut the vegetables into large dice for the base", + "dice into one-centimeter cubes for the aromatic base", + "cut into large rough dice to flavor the stock", + ], + ), + ), + TechStepTrainingEntry( + uid="paysanne", + fr=TechStepLocaleTrainingData( + synonyms=["paysanne", "en paysanne", "tailler en paysanne", "couper en paysanne"], + utterances=[ + "couper les légumes en paysanne", + "tailler en fins triangles réguliers", + "détailler en tranches triangulaires avant de faire suer", + "couper en losanges fins pour le potage", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["paysanne cut", "cut into paysanne", "thin triangular cut"], + utterances=[ + "cut the vegetables paysanne-style", + "cut into thin, even triangles", + "cut into triangular slices before sweating", + "cut into thin diamonds for the soup", + ], + ), + ), + TechStepTrainingEntry( + uid="blindBake", + fr=TechStepLocaleTrainingData( + synonyms=["cuire à blanc", "cuisson à blanc", "précuire le fond de tarte"], + utterances=[ + "cuire le fond de tarte à blanc avant de le garnir", + "précuire la pâte à vide avec des poids de cuisson", + "faire cuire la pâte seule quelques minutes avant d'ajouter la garniture", + "enfourner le fond de tarte vide recouvert de billes de cuisson", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["blind bake", "blind-baked", "blind baking", "pre-bake the crust"], + utterances=[ + "blind bake the tart shell before filling it", + "pre-bake the empty crust with baking weights", + "bake the crust alone for a few minutes before adding the filling", + "bake the empty tart shell topped with baking beans", + ], + ), + ), + TechStepTrainingEntry( + uid="bainMarie", + fr=TechStepLocaleTrainingData( + synonyms=["bain-marie", "au bain-marie", "cuisson au bain-marie"], + utterances=[ + "cuire la crème au bain-marie", + "placer le récipient dans un fond d'eau chaude pour une cuisson douce", + "faire chauffer doucement au bain-marie pour ne pas le faire tourner", + "réchauffer la sauce au bain-marie sans qu'elle bouille", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["bain-marie", "water bath", "in a water bath", "double boiler"], + utterances=[ + "cook the custard in a bain-marie", + "place the container in a pan of hot water for gentle cooking", + "warm it gently over a water bath so it doesn't split", + "reheat the sauce in a double boiler without boiling it", + ], + ), + ), + TechStepTrainingEntry( + uid="smother", + fr=TechStepLocaleTrainingData( + synonyms=["étouffée", "à l'étouffée", "étuver", "étuvez", "étuvé", "cuisson à l'étuvée"], + utterances=[ + "cuire les légumes à l'étouffée dans un corps gras", + "laisser cuire à couvert à très basse température", + "étuver doucement pendant une longue durée", + "cuire lentement à feu très doux dans un récipient fermé", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["smother", "smothered", "cook covered on low heat", "stew gently covered"], + utterances=[ + "smother the vegetables in fat, covered", + "let it cook covered at very low temperature", + "cook it gently over a long time, covered", + "cook slowly over very low heat in a closed pot", + ], + ), + ), + TechStepTrainingEntry( + uid="decant", + fr=TechStepLocaleTrainingData( + synonyms=["décanter", "décantez", "décanté", "décantée", "décantation"], + utterances=[ + "laisser décanter le jus avant de le transvaser", + "transvaser délicatement en laissant le dépôt au fond", + "laisser reposer puis verser doucement dans un autre récipient", + "séparer le liquide clair du dépôt qui s'est formé au fond", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["decant", "decants", "decanted", "decanting"], + utterances=[ + "let the juice decant before pouring it off", + "carefully pour it off, leaving the sediment behind", + "let it settle then gently pour into another container", + "separate the clear liquid from the sediment that formed at the bottom", + ], + ), + ), + TechStepTrainingEntry( + uid="dilute", + fr=TechStepLocaleTrainingData( + synonyms=["délayer", "délayez", "délayé", "délayée", "diluer dans un liquide"], + utterances=[ + "délayer la farine dans un peu de lait froid", + "diluer la maïzena dans de l'eau avant de l'incorporer", + "mélanger la poudre avec un peu de liquide pour la dissoudre", + "incorporer progressivement le liquide en délayant bien", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["dilute", "dilutes", "diluted", "diluting", "mix into a liquid"], + utterances=[ + "dilute the flour in a little cold milk", + "dilute the cornstarch in water before adding it", + "mix the powder with a little liquid to dissolve it", + "gradually stir in the liquid, mixing well as you go", + ], + ), + ), + TechStepTrainingEntry( + uid="punchDown", + fr=TechStepLocaleTrainingData( + synonyms=["dégazer", "dégazez", "dégazé", "dégazage", "chasser l'air de la pâte"], + utterances=[ + "dégazer la pâte après la première pousse", + "pétrir légèrement pour chasser l'air de la pâte", + "aplatir la pâte au rouleau pour en retirer le gaz", + "presser la pâte pour en faire sortir les bulles d'air", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["punch down", "punch down the dough", "knock back", "deflate the dough"], + utterances=[ + "punch down the dough after the first rise", + "gently knead to knock the air out of the dough", + "flatten the dough with a rolling pin to release the gas", + "press the dough to push out the air bubbles", + ], + ), + ), + TechStepTrainingEntry( + uid="disgorge", + fr=TechStepLocaleTrainingData( + synonyms=["dégorger", "dégorgez", "dégorgé", "dégorgée", "faire dégorger"], + utterances=[ + "faire dégorger les concombres avec du sel", + "saler les légumes pour qu'ils perdent leur eau", + "laisser tremper la viande dans l'eau froide vinaigrée", + "laisser reposer avec du sel pour évacuer l'excès d'humidité", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["disgorge", "purge", "salt and drain", "draw out the moisture"], + utterances=[ + "salt the cucumbers to draw out their moisture", + "salt the vegetables so they release their water", + "soak the meat in cold vinegared water", + "let it sit with salt to remove excess moisture", + ], + ), + ), + TechStepTrainingEntry( + uid="loosen", + fr=TechStepLocaleTrainingData( + synonyms=["détendre", "détendez", "détendu", "détendue", "assouplir la préparation"], + utterances=[ + "détendre la pâte avec un peu de lait", + "ajouter un peu de crème pour assouplir la sauce", + "incorporer un œuf battu pour rendre la pâte plus fluide", + "allonger la préparation avec un peu de liquide", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["loosen", "loosens", "loosened", "loosening", "thin out the batter"], + utterances=[ + "loosen the batter with a little milk", + "add a little cream to loosen the sauce", + "stir in a beaten egg to make the batter more fluid", + "thin out the mixture with a little liquid", + ], + ), + ), + TechStepTrainingEntry( + uid="shellEgg", + fr=TechStepLocaleTrainingData( + synonyms=["écaler", "écalez", "écalé", "écalée", "retirer la coquille de l'œuf"], + utterances=[ + "écaler les œufs durs sous l'eau froide", + "retirer délicatement la coquille de l'œuf cuit", + "enlever la coquille des œufs mollets", + "peler l'œuf dur après l'avoir refroidi", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["shell the egg", "shelled the egg", "remove the eggshell"], + utterances=[ + "shell the hard-boiled eggs under cold water", + "gently remove the shell from the cooked egg", + "remove the shell from the soft-boiled eggs", + "peel the hard-boiled egg after cooling it", + ], + ), + ), + TechStepTrainingEntry( + uid="scald", + fr=TechStepLocaleTrainingData( + synonyms=["échauder", "échaudez", "échaudé", "échaudée", "ébouillanter brièvement"], + utterances=[ + "échauder les tomates pour retirer la peau facilement", + "plonger brièvement dans l'eau bouillante avant de peler", + "ébouillanter quelques secondes pour faciliter l'épluchage", + "tremper rapidement dans l'eau chaude pour détacher la peau", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["scald", "scalds", "scalded", "scalding", "briefly blanch to peel"], + utterances=[ + "scald the tomatoes to easily remove the skin", + "briefly dip in boiling water before peeling", + "scald for a few seconds to make peeling easier", + "quickly dip in hot water to loosen the skin", + ], + ), + ), + TechStepTrainingEntry( + uid="pod", + fr=TechStepLocaleTrainingData( + synonyms=["écosser", "écossez", "écossé", "écossée", "retirer la cosse"], + utterances=[ + "écosser les petits pois avant de les cuire", + "retirer la cosse des fèves fraîches", + "enlever l'enveloppe des haricots avant de les préparer", + "sortir les grains de leur cosse", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["pod", "shell the peas", "remove the pods"], + utterances=[ + "pod the peas before cooking them", + "remove the pods from the fresh fava beans", + "remove the shells from the beans before preparing them", + "take the grains out of their pods", + ], + ), + ), + TechStepTrainingEntry( + uid="emulsify", + fr=TechStepLocaleTrainingData( + synonyms=["émulsionner", "émulsionnez", "émulsionné", "émulsionnée", "monter en émulsion"], + utterances=[ + "émulsionner l'huile et le vinaigre pour la vinaigrette", + "fouetter énergiquement pour lier l'huile et l'eau", + "monter la sauce en émulsion en ajoutant l'huile petit à petit", + "mélanger vigoureusement pour obtenir un mélange homogène et lisse", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["emulsify", "emulsifies", "emulsified", "emulsifying"], + utterances=[ + "emulsify the oil and vinegar for the dressing", + "whisk vigorously to bind the oil and water together", + "build the emulsion by adding the oil little by little", + "mix vigorously until smooth and even", + ], + ), + ), + TechStepTrainingEntry( + uid="hollowOut", + fr=TechStepLocaleTrainingData( + synonyms=["évider", "évidez", "évidé", "évidée", "retirer la chair du fruit"], + utterances=[ + "évider les tomates avant de les farcir", + "creuser délicatement la courgette pour retirer la chair", + "retirer le cœur et les pépins du fruit à la cuillère", + "vider l'intérieur du légume avant de le garnir", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["hollow out", "hollowed out", "scoop out the flesh", "core out"], + utterances=[ + "hollow out the tomatoes before stuffing them", + "gently scoop out the zucchini to remove the flesh", + "remove the core and seeds from the fruit with a spoon", + "scoop out the inside of the vegetable before filling it", + ], + ), + ), + TechStepTrainingEntry( + uid="shock", + fr=TechStepLocaleTrainingData( + synonyms=["frapper", "frappez", "frappé", "frappée", "bain de glace"], + utterances=[ + "frapper les légumes dans l'eau glacée après cuisson", + "plonger immédiatement dans l'eau glacée pour stopper la cuisson", + "refroidir rapidement dans un bain d'eau et de glace", + "passer sous l'eau très froide pour préserver la couleur", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["shock", "shocked", "shock in ice water", "ice bath"], + utterances=[ + "shock the vegetables in ice water after cooking", + "plunge immediately into ice water to stop the cooking", + "cool quickly in an ice bath", + "run under very cold water to preserve the color", + ], + ), + ), + TechStepTrainingEntry( + uid="setGel", + fr=TechStepLocaleTrainingData( + synonyms=["gélifier", "gélifiez", "gélifié", "gélifiée", "prendre en gelée"], + utterances=[ + "ajouter de la gélatine pour gélifier la préparation", + "laisser prendre au réfrigérateur jusqu'à ce que ça gélifie", + "incorporer l'agar-agar pour obtenir une texture de gelée", + "laisser figer la préparation jusqu'à ce qu'elle soit ferme", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["set with gelatin", "gel", "gelled", "set into a jelly"], + utterances=[ + "add gelatin to set the mixture", + "let it set in the fridge until it gels", + "stir in the agar-agar to get a jelly-like texture", + "let the mixture firm up until set", + ], + ), + ), + TechStepTrainingEntry( + uid="glaze", + fr=TechStepLocaleTrainingData( + synonyms=["glacer", "glacez", "glacé", "glacée", "glaçage"], + utterances=[ + "glacer les carottes avec du beurre et du sucre", + "napper la pâtisserie d'un glaçage brillant", + "arroser la viande de son jus pour la faire glacer au four", + "saupoudrer de sucre glace et passer sous le grill", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["glaze", "glazes", "glazed", "glazing"], + utterances=[ + "glaze the carrots with butter and sugar", + "coat the pastry with a shiny glaze", + "baste the meat with its juices to glaze it in the oven", + "dust with powdered sugar and run under the broiler", + ], + ), + ), + TechStepTrainingEntry( + uid="thicken", + fr=TechStepLocaleTrainingData( + synonyms=["lier", "liez", "liée", "liés", "liaison de la sauce"], + utterances=[ + "lier la sauce avec un jaune d'œuf", + "épaissir le potage avec un peu de farine", + "ajouter de la crème pour donner plus de consistance à la sauce", + "incorporer la maïzena pour épaissir le jus", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["thicken", "thickens", "thickened", "thickening", "bind the sauce"], + utterances=[ + "thicken the sauce with an egg yolk", + "thicken the soup with a little flour", + "add cream to give the sauce more body", + "stir in cornstarch to thicken the juices", + ], + ), + ), + TechStepTrainingEntry( + uid="filet", + fr=TechStepLocaleTrainingData( + synonyms=["lever les filets", "faire lever", "désosser le poisson", "lever un filet"], + utterances=[ + "lever les filets du poisson à l'aide d'un couteau fin", + "faire lever la peau du poisson par le poissonnier", + "retirer les filets de la volaille en suivant l'os", + "désosser et lever les filets avant de cuisiner", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["fillet", "filleted", "filleting", "fillet the fish"], + utterances=[ + "fillet the fish with a thin knife", + "have the fishmonger skin the fish", + "remove the fillets from the poultry along the bone", + "bone out and fillet before cooking", + ], + ), + ), + TechStepTrainingEntry( + uid="proof", + fr=TechStepLocaleTrainingData( + synonyms=["laisser pousser", "faire pousser la pâte", "laisser lever", "temps de pousse"], + utterances=[ + "laisser pousser la pâte à pain une heure dans un endroit tiède", + "laisser la pâte à pizza doubler de volume", + "laisser reposer la pâte à brioche jusqu'à ce qu'elle gonfle", + "attendre que la levure fasse son effet et que la pâte lève", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["proof", "proofed", "proofing", "let the dough rise", "let it rise"], + utterances=[ + "let the bread dough rise for an hour in a warm place", + "let the pizza dough double in size", + "let the brioche dough rest until it puffs up", + "wait for the yeast to work and the dough to rise", + ], + ), + ), + TechStepTrainingEntry( + uid="peelBlanch", + fr=TechStepLocaleTrainingData( + synonyms=["monder", "mondez", "mondé", "mondée", "émonder", "émondez"], + utterances=[ + "monder les tomates en les plongeant dans l'eau bouillante", + "émonder les amandes pour retirer leur peau", + "plonger les fruits quelques secondes dans l'eau bouillante pour les peler facilement", + "peler les châtaignes après les avoir ébouillantées", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["peel by blanching", "blanch and peel", "skin after scalding"], + utterances=[ + "peel the tomatoes by dipping them in boiling water", + "blanch the almonds to remove their skins", + "dip the fruit briefly in boiling water to peel it easily", + "peel the chestnuts after scalding them", + ], + ), + ), + TechStepTrainingEntry( + uid="whipUp", + fr=TechStepLocaleTrainingData( + synonyms=["monter", "montez", "monté", "montée", "faire monter", "monter au fouet"], + utterances=[ + "monter la crème en chantilly", + "faire monter les blancs en neige ferme", + "battre au fouet électrique jusqu'à ce que le volume double", + "fouetter jusqu'à obtenir une préparation bien ferme et aérée", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["whip up", "whipped up", "build volume", "whip to stiff peaks"], + utterances=[ + "whip the cream into chantilly", + "whip the egg whites to stiff peaks", + "beat with an electric mixer until the volume doubles", + "whisk until the mixture is firm and airy", + ], + ), + ), + TechStepTrainingEntry( + uid="moisten", + fr=TechStepLocaleTrainingData( + synonyms=["mouiller", "mouillez", "mouillé", "mouillée", "mouillement"], + utterances=[ + "mouiller la préparation avec un peu de bouillon", + "ajouter de l'eau pour détendre et humidifier le mélange", + "verser un peu de lait pour réhydrater la pâte", + "incorporer un peu de liquide pour assouplir la préparation", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["moisten", "moistens", "moistened", "moistening", "add liquid"], + utterances=[ + "moisten the mixture with a little stock", + "add water to loosen and moisten the mixture", + "pour in a little milk to rehydrate the batter", + "stir in a little liquid to soften the mixture", + ], + ), + ), + TechStepTrainingEntry( + uid="pasteurize", + fr=TechStepLocaleTrainingData( + synonyms=["pasteuriser", "pasteurisez", "pasteurisé", "pasteurisée", "pasteurisation"], + utterances=[ + "pasteuriser le lait en le chauffant sans le faire bouillir", + "chauffer le jus de fruits pour éliminer les germes", + "porter le liquide à une température précise puis le refroidir brusquement", + "traiter le lait par la chaleur pour le conserver plus longtemps", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["pasteurize", "pasteurizes", "pasteurized", "pasteurizing"], + utterances=[ + "pasteurize the milk by heating it without boiling", + "heat the fruit juice to eliminate germs", + "bring the liquid to a precise temperature then cool it quickly", + "heat-treat the milk to preserve it longer", + ], + ), + ), + TechStepTrainingEntry( + uid="poach", + fr=TechStepLocaleTrainingData( + synonyms=["pocher", "pochez", "poché", "pochée", "pochées", "cuisson pochée"], + utterances=[ + "pocher les œufs dans l'eau frémissante", + "cuire le poisson à peine frémissant dans un bouillon", + "immerger la volaille dans un liquide à peine frémissant", + "laisser cuire doucement dans un fumet sans faire bouillir", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["poach", "poaches", "poached", "poaching"], + utterances=[ + "poach the eggs in simmering water", + "cook the fish in barely simmering stock", + "submerge the poultry in a barely simmering liquid", + "let it cook gently in a stock without boiling", + ], + ), + ), + TechStepTrainingEntry( + uid="reduce", + fr=TechStepLocaleTrainingData( + synonyms=["réduire", "réduisez", "réduit", "réduite", "faire réduire", "réduction"], + utterances=[ + "faire réduire la sauce de moitié", + "laisser réduire à feu vif pour concentrer les saveurs", + "augmenter le feu pour évaporer une partie du liquide", + "laisser mijoter à découvert jusqu'à ce que le jus épaississe", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["reduce", "reduces", "reduced", "reducing", "reduction"], + utterances=[ + "reduce the sauce by half", + "let it reduce over high heat to concentrate the flavors", + "increase the heat to evaporate some of the liquid", + "let it simmer uncovered until the liquid thickens", + ], + ), + ), + TechStepTrainingEntry( + uid="rubIn", + fr=TechStepLocaleTrainingData( + synonyms=["sabler", "sablez", "sablé", "sablée", "pâte sablée", "sabler la pâte"], + utterances=[ + "sabler la farine et le beurre du bout des doigts", + "malaxer rapidement pour obtenir une texture sableuse", + "frotter le beurre et la farine entre les doigts jusqu'à obtenir une texture friable", + "travailler la pâte sans la chauffer pour la rendre poudreuse", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["rub in", "rubbed in", "rub the butter into the flour", "sandy texture"], + utterances=[ + "rub the flour and butter between your fingertips", + "quickly work it into a sandy texture", + "rub the butter into the flour until it looks like breadcrumbs", + "work the dough without warming it so it stays crumbly", + ], + ), + ), + TechStepTrainingEntry( + uid="dustWithFlour", + fr=TechStepLocaleTrainingData( + synonyms=["singer", "singez", "singé", "singée", "saupoudrer de farine dans le corps gras"], + utterances=[ + "singer les légumes avec une cuillère de farine", + "saupoudrer de farine et laisser cuire quelques minutes avant de mouiller", + "ajouter la farine sur les aliments dorés et laisser cuire un instant", + "fariner légèrement la préparation avant d'ajouter le liquide", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["dust with flour", "stir in flour", "sprinkle flour over the fat"], + utterances=[ + "dust the vegetables with a spoonful of flour", + "sprinkle with flour and cook a few minutes before adding liquid", + "add the flour over the browned food and cook briefly", + "lightly flour the mixture before adding the liquid", + ], + ), + ), + TechStepTrainingEntry( + uid="sweat", + fr=TechStepLocaleTrainingData( + synonyms=["suer", "faire suer", "faites suer", "suez", "sué"], + utterances=[ + "faire suer les oignons à feu doux sans coloration", + "laisser suer les légumes émincés dans le beurre", + "cuire doucement à couvert pour faire perdre leur eau aux légumes", + "faire revenir sans coloration à feu très doux", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["sweat", "sweats", "sweated", "sweating"], + utterances=[ + "sweat the onions over low heat without browning", + "let the sliced vegetables sweat in the butter", + "cook gently, covered, to draw the water out of the vegetables", + "cook without browning over very low heat", + ], + ), + ), + TechStepTrainingEntry( + uid="sift", + fr=TechStepLocaleTrainingData( + synonyms=["tamiser", "tamisez", "tamisé", "tamisée", "passer au tamis"], + utterances=[ + "tamiser la farine avant de l'incorporer", + "passer le sucre glace au tamis pour retirer les grumeaux", + "faire passer la poudre d'amande à travers une passoire fine", + "filtrer la farine pour obtenir une texture fine et homogène", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["sift", "sifts", "sifted", "sifting"], + utterances=[ + "sift the flour before adding it", + "sift the powdered sugar to remove any lumps", + "pass the almond flour through a fine sieve", + "strain the flour to get a fine, even texture", + ], + ), + ), + TechStepTrainingEntry( + uid="toast", + fr=TechStepLocaleTrainingData( + synonyms=["torréfier", "torréfiez", "torréfié", "torréfiée", "torréfaction"], + utterances=[ + "torréfier les grains de café à la poêle", + "faire griller les fruits secs à sec pour développer leur arôme", + "passer les épices quelques minutes dans une poêle chaude sans matière grasse", + "faire dorer les amandes à sec au four", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["toast", "toasts", "toasted", "toasting", "dry-roast"], + utterances=[ + "toast the coffee beans in a pan", + "dry-toast the nuts to develop their flavor", + "toast the spices for a few minutes in a hot, dry pan", + "dry-roast the almonds in the oven", + ], + ), + ), + TechStepTrainingEntry( + uid="zest", + fr=TechStepLocaleTrainingData( + synonyms=["zester", "zestez", "zesté", "zestée", "prélever le zeste"], + utterances=[ + "zester le citron avant de le presser", + "prélever le zeste de l'orange à l'aide d'une râpe fine", + "râper finement la peau de l'agrume sans toucher la partie blanche", + "récupérer l'écorce colorée du citron vert pour parfumer la préparation", + ], + ), + en=TechStepLocaleTrainingData( + synonyms=["zest", "zests", "zested", "zesting"], + utterances=[ + "zest the lemon before juicing it", + "grate the zest of the orange with a fine grater", + "finely grate the citrus peel without touching the white pith", + "collect the colored peel of the lime to flavor the mixture", + ], + ), + ), +] + + +def entries_for_locale(locale: str) -> list[dict]: + """Aplati {@link TECH_STEP_TRAINING_DATA} en une liste `{uid, synonyms, + utterances}` pour une seule locale — la forme que + `LocalePipeline.train()` (via `TrainEntry`) attend. Retourne des `dict` + plutôt que `TrainEntry` directement pour ne pas faire dépendre ce module, + purement données, de `locale_pipeline.py`.""" + return [ + {"uid": entry.uid, **vars(getattr(entry, locale))} + for entry in TECH_STEP_TRAINING_DATA + if hasattr(entry, locale) + ] diff --git a/services/tech-step-intent-service/tests/conftest.py b/services/tech-step-intent-service/tests/conftest.py index 1c4f5d9..6b17b7d 100644 --- a/services/tech-step-intent-service/tests/conftest.py +++ b/services/tech-step-intent-service/tests/conftest.py @@ -2,9 +2,30 @@ `INTENT_SERVICE_SECRET` est absent — cette variable doit donc être définie avant le tout premier `import intent_service...` de la session pytest. `conftest.py` est chargé par pytest avant la collecte des modules de test, -donc avant que `test_routes_*.py`/`test_security.py` n'importent -`intent_service.main`.""" +donc avant que `test_routes_process.py`/`test_security.py` n'importent +`intent_service.main`. +""" import os os.environ.setdefault("INTENT_SERVICE_SECRET", "pytest-only-secret-not-used-anywhere-else-32ch") + +import pytest # noqa: E402 — après le `setdefault` ci-dessus, voir le docstring. +from fastapi.testclient import TestClient # noqa: E402 + +from intent_service.main import app # noqa: E402 + + +@pytest.fixture(scope="session") +def client(): + """`TestClient(app)` utilisé comme gestionnaire de contexte déclenche le + vrai `lifespan` — puisque `main.py`'s `lifespan` entraîne maintenant + l'intégralité du vrai corpus `training_data.TECH_STEP_TRAINING_DATA` + (pas un jeu jouet, voir `PipelineRegistry.initialize`), refaire ça une + fois par fichier de test (ou pire, une fois par test) multiplierait un + entraînement non négligeable sur toute la suite pour rien — scope + "session" pour que chaque test ayant besoin d'une vraie app en cours + d'exécution partage la même instance déjà entraînée. + """ + with TestClient(app) as test_client: + yield test_client diff --git a/services/tech-step-intent-service/tests/test_logging_config.py b/services/tech-step-intent-service/tests/test_logging_config.py index ad85bf1..fe5aa1e 100644 --- a/services/tech-step-intent-service/tests/test_logging_config.py +++ b/services/tech-step-intent-service/tests/test_logging_config.py @@ -1,6 +1,7 @@ """Vérifie le format des lignes de log produites par -`logging_config._JsonFormatter` — ce que `routes/process.py`/`routes/train.py` -utilisent pour journaliser l'input/l'output de chaque appel NLP.""" +`logging_config._JsonFormatter` — ce que `routes/process.py` et +`pipeline_registry.py` utilisent pour journaliser l'input/l'output de +chaque appel NLP et le déroulement de l'entraînement au démarrage.""" import json import logging diff --git a/services/tech-step-intent-service/tests/test_routes_process.py b/services/tech-step-intent-service/tests/test_routes_process.py index c2e08a4..443d698 100644 --- a/services/tech-step-intent-service/tests/test_routes_process.py +++ b/services/tech-step-intent-service/tests/test_routes_process.py @@ -1,52 +1,31 @@ -"""Contrat JSON de `POST /v1/process` — voir `schemas.py`/`routes/process.py`.""" +"""Contrat JSON de `POST /v1/process` — voir `schemas.py`/`routes/process.py`. + +Le service s'entraîne désormais lui-même au démarrage sur le vrai corpus +(`training_data.TECH_STEP_TRAINING_DATA`, voir `conftest.py`'s fixture +`client` partagée) — ces tests vérifient donc le contrat HTTP contre des +phrases réelles du corpus, plus besoin d'un `POST /v1/train` préalable avec +des données jouets. +""" -import pytest from fastapi.testclient import TestClient from intent_service.config import settings -from intent_service.main import app _HEADERS = {"X-Intent-Service-Secret": settings.intent_service_secret} -@pytest.fixture -def client(): - with TestClient(app) as test_client: - yield test_client - - -def test_process_against_an_untrained_locale_returns_empty_result(client: TestClient): +def test_process_against_an_unsupported_locale_returns_empty_result(client: TestClient): + # "de" n'a aucun modèle spaCy connu (`SUPPORTED_LOCALES`) — se comporte + # comme "jamais entraîné" côté `/v1/process`, jamais une erreur (voir + # `PipelineRegistry.process`). response = client.post( - "/v1/process", headers=_HEADERS, json={"locale": "fr", "text": "faire mijoter à feu doux"} + "/v1/process", headers=_HEADERS, json={"locale": "de", "text": "faire mijoter à feu doux"} ) assert response.status_code == 200 assert response.json() == {"entities": [], "intent": None, "score": 0.0} -def test_process_after_train_returns_entities_and_intent(client: TestClient): - client.post( - "/v1/train", - headers=_HEADERS, - json={ - "locale": "fr", - "entries": [ - # `textcat` (exclusive_classes) exige >= 2 labels (voir - # LocalePipeline.train) — un second label est nécessaire - # même si ce test ne vérifie que celui de "simmer". - { - "uid": "simmer", - "synonyms": ["mijoter"], - "utterances": ["faire mijoter à feu doux", "laisser mijoter à couvert"], - }, - { - "uid": "boil", - "synonyms": ["bouillir"], - "utterances": ["faire bouillir l'eau", "porter à ébullition"], - }, - ], - }, - ) - +def test_process_returns_entities_and_intent_for_a_real_corpus_sentence(client: TestClient): response = client.post( "/v1/process", headers=_HEADERS, json={"locale": "fr", "text": "Faire mijoter à feu doux"} ) @@ -57,15 +36,16 @@ def test_process_after_train_returns_entities_and_intent(client: TestClient): assert [entity["uid"] for entity in body["entities"]] == ["simmer"] -def test_process_with_blank_text_returns_empty_result(client: TestClient): - client.post( - "/v1/train", - headers=_HEADERS, - json={ - "locale": "en", - "entries": [{"uid": "boil", "synonyms": ["boil"], "utterances": ["bring to the boil"]}], - }, +def test_process_matches_english_text_against_the_english_trained_vocabulary(client: TestClient): + response = client.post( + "/v1/process", headers=_HEADERS, json={"locale": "en", "text": "Chop the onions finely"} ) - response = client.post("/v1/process", headers=_HEADERS, json={"locale": "en", "text": " "}) + assert response.status_code == 200 + body = response.json() + assert [entity["uid"] for entity in body["entities"]] == ["chop"] + + +def test_process_with_blank_text_returns_empty_result(client: TestClient): + response = client.post("/v1/process", headers=_HEADERS, json={"locale": "fr", "text": " "}) assert response.status_code == 200 assert response.json() == {"entities": [], "intent": None, "score": 0.0} diff --git a/services/tech-step-intent-service/tests/test_routes_train.py b/services/tech-step-intent-service/tests/test_routes_train.py deleted file mode 100644 index 415cdbe..0000000 --- a/services/tech-step-intent-service/tests/test_routes_train.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Contrat JSON de `POST /v1/train` — voir `schemas.py`/`routes/train.py`.""" - -import pytest -from fastapi.testclient import TestClient - -from intent_service.config import settings -from intent_service.main import app - -_HEADERS = {"X-Intent-Service-Secret": settings.intent_service_secret} - - -@pytest.fixture -def client(): - with TestClient(app) as test_client: - yield test_client - - -def test_train_returns_counts(client: TestClient): - response = client.post( - "/v1/train", - headers=_HEADERS, - json={ - "locale": "fr", - "entries": [ - {"uid": "melt", "synonyms": ["faire fondre"], "utterances": ["faire fondre le beurre"]}, - {"uid": "boil", "synonyms": ["bouillir"], "utterances": ["faire bouillir l'eau"]}, - ], - }, - ) - assert response.status_code == 200 - body = response.json() - assert body == {"locale": "fr", "labelCount": 2, "utteranceCount": 2, "synonymCount": 2} - - -def test_train_with_unsupported_locale_returns_422(client: TestClient): - response = client.post( - "/v1/train", - headers=_HEADERS, - json={"locale": "de", "entries": [{"uid": "melt", "synonyms": [], "utterances": []}]}, - ) - assert response.status_code == 422 - - -def test_train_with_empty_entries_returns_zero_counts(client: TestClient): - response = client.post("/v1/train", headers=_HEADERS, json={"locale": "en", "entries": []}) - assert response.status_code == 200 - assert response.json() == {"locale": "en", "labelCount": 0, "utteranceCount": 0, "synonymCount": 0} diff --git a/services/tech-step-intent-service/tests/test_security.py b/services/tech-step-intent-service/tests/test_security.py index f078b5e..6b5fe6a 100644 --- a/services/tech-step-intent-service/tests/test_security.py +++ b/services/tech-step-intent-service/tests/test_security.py @@ -1,17 +1,12 @@ """`require_valid_secret` — miroir inversé de -`require-internal-worker.test.ts` côté `apps/api`.""" +`require-internal-worker.test.ts` côté `apps/api`. Utilise la fixture +`client` partagée (`conftest.py`) — pas besoin d'une app entraînée +séparément juste pour tester l'authentification. +""" -import pytest from fastapi.testclient import TestClient from intent_service.config import settings -from intent_service.main import app - - -@pytest.fixture -def client(): - with TestClient(app) as test_client: - yield test_client def test_rejects_a_missing_secret(client: TestClient): diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index 817f9de..1e68513 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -479,10 +479,13 @@ appelé en HTTP par `TechStepClassifierService` via `IntentServiceClient` (`intent-service-client.ts`) — `node-nlp` était peu maintenu et tournait in-process dans l'event loop Node ; spaCy offre un écosystème NLP plus robuste, dans un processus séparé, avec l'ambition à terme de pouvoir aussi -absorber ce que fait `services/tech-step-llm-worker`. `TECH_STEP_TRAINING_DATA` -reste possédé par `apps/api` (revu par PR comme le reste du code) et poussé -intégralement à ce service via `POST /v1/train` à chaque warm-up — ce service -ne touche jamais Postgres lui-même (voir son propre README). +absorber ce que fait `services/tech-step-llm-worker`. Ce service est +entièrement autonome : `TECH_STEP_TRAINING_DATA` (~74 techniques) vit +désormais dans son propre `training_data.py`, revu par PR comme le reste du +code mais plus poussé par `apps/api` via HTTP — le service s'entraîne +lui-même une seule fois, à son propre démarrage, et ne touche jamais +Postgres (voir son propre README, y compris pour le temps de démarrage — +plusieurs minutes, l'entraînement n'étant jamais persisté sur disque). `normalizeText` (décomposition NFD + suppression des diacritiques + minuscule) reste utilisée par `ingredient-matcher.ts`, mais n'intervient plus dans la @@ -516,12 +519,15 @@ normalisation du pipeline spaCy côté service. un match clairement ancré sur un mot-clé juste parce que le 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). -`server.ts` appelle `techStepClassifier.warmUp()` avant d'accepter du trafic, -avec retry/backoff si `services/tech-step-intent-service` n'est pas encore -prêt (le cas normal en Docker Compose, où `app` attend qu'il soit `healthy` -avant même de démarrer — voir `docker-compose.yml`). +Résolution `TechStep.key -> id` mémoïsée une seule fois sur le singleton +partagé `techStepClassifier` (jamais par requête) — c'est tout ce +qu'`apps/api` a encore à mémoïser, l'entraînement du modèle lui-même vivant +entièrement côté `services/tech-step-intent-service`. `server.ts` appelle +`techStepClassifier.warmUp()` avant d'accepter du trafic, avec retry/backoff +si `services/tech-step-intent-service` n'est pas encore joignable (le cas +normal en Docker Compose, où `app` attend qu'il soit `healthy` avant même de +démarrer — voir `docker-compose.yml`, et le README de ce service pour +combien de temps ça prend). **Pièges rencontrés en construisant ce pipeline**, tous corrigés dans le code (pas juste contournés) : @@ -538,10 +544,10 @@ avant même de démarrer — voir `docker-compose.yml`). défaut — persistait le modèle entraîné dans un fichier `model.nlp` (cwd du process) et le rechargeait *au lieu de* ré-entraîner au prochain démarrage s'il existait déjà. Un modèle obsolète sur disque aurait masqué - silencieusement toute mise à jour de `TECH_STEP_TRAINING_DATA`. Non - applicable au service Python actuel : `POST /v1/train` reconstruit tout en - mémoire à chaque appel, sans jamais rien persister sur disque (voir ce - service's own README). + silencieusement toute mise à jour du corpus. Non applicable au service + Python actuel : il réentraîne tout en mémoire à chaque démarrage du + process, sans jamais rien persister sur disque (voir ce service's own + README). - `nlp.make_doc()` (spaCy) ne fait tourner que le tokenizer, pas les composants du pipeline — un piège trouvé en construisant le `PhraseMatcher` du nouveau service : les patterns de synonymes doivent explicitement diff --git a/specs/batch-cooking-modele.md b/specs/batch-cooking-modele.md index a1f2269..1627137 100644 --- a/specs/batch-cooking-modele.md +++ b/specs/batch-cooking-modele.md @@ -353,9 +353,9 @@ fiable. `tech_step` (`TechStep`, `key` unique, ex. `"simmer"`) est le catalogue des techniques (mijoter, préchauffer…) — juste un id/clé stable référencé par `step_tech_step`. Les données de détection elles-mêmes (synonymes + phrases -d'exemple par langue, entraînant le microservice spaCy -`services/tech-step-intent-service`) vivent en code -(`tech-step-training-data.ts`), pas dans une table — l'ancienne +d'exemple par langue) vivent en code dans le microservice spaCy lui-même +(`services/tech-step-intent-service/intent_service/training_data.py`), pas +dans une table ni côté `apps/api` — l'ancienne `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