* feat(tech-steps): fiabilise la detection des tech steps (corpus + LLM + corrections utilisateur)
Une seule feature livree en une seule PR, en 5 phases :
- Phase 1 : enrichit le corpus NLP (tech-step-training-data.ts) et ajoute
un harness d'evaluation (precision/rappel/F1) avec un jeu de test etiquete
- la premiere metrique objective de qualite pour ce classifieur.
- Phase 2 : schema Prisma (StepTechStepCorrection, TechStepTrainingSuggestion)
+ endpoints utilisateur (POST/GET corrections, ouverts a tout viewer, pas
seulement l'auteur) + endpoints internes /internal/tech-steps/* proteges
par secret partage (requireInternalWorker).
- Phase 3 : UI de highlight/correction cote web (selection de texte ->
association a une technique, ou clic sur un highlight existant pour le
corriger/supprimer) - verifiee via Cypress (component + e2e, en Chrome
reel).
- Phase 4 : worker LLM autonome (services/tech-step-llm-worker, hors du
monorepo pnpm comme experiments/llm-tech-step-poc) qui audite les clauses
a faible confiance et transforme les corrections utilisateur en
suggestions d'entrainement, sans jamais toucher le chemin interactif.
- Phase 5 : script retrain-tech-steps.ts (gate de regression F1 + backfill)
et list-pending-training-suggestions.ts pour la revue humaine avant
application au corpus.
Verification effectuee cette session : tsc/biome sur l'ensemble du repo,
build complet (pnpm build), suite Cypress complete (component 39/39, e2e
75/76 - le seul echec est preexistant et sans rapport, cote
recipe-form.feature/ingredient-picker), tests unitaires du worker (6/6) et
son install/typecheck reels contre node-llama-cpp. Les tests Mocha
d'apps/api (Phases 1 et 2) n'ont pas pu etre executes dans cette session
(pas de Postgres local disponible) - a lancer avant merge.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(tech-steps): calibre le seuil F1 sur une vraie execution et corrige un bug de comptage
Docker etant redevenu disponible dans cette session, j'ai pu lancer pour de
vrai la suite Mocha d'apps/api (334/334, y compris les tests Phase 1/2
qui n'avaient pu etre executes precedemment) ainsi que les scripts de la
Phase 5 contre une vraie base de test.
- tech-step-eval-dataset.ts : corrige un vrai bug d'auteur - "Take the
plates..." collisionnait avec le synonyme anglais enregistre "plates"
(technique plate), invalidant ce cas negatif. Remplace par "dishes".
- tech-step-eval-runner.ts : F1 reel mesure = 0.815 (33 TP / 9 FP / 6 FN).
Documente ce chiffre et les vraies erreurs de classification decouvertes
(ex: "Blanchissez les haricots verts..." classifie a tort comme "peel")
- des faiblesses reelles du classifieur que ce harness est cense
detecter, pas a masquer en ajustant le jeu de test.
- retrain-tech-steps.ts : le script loggait `appliedIds.length`/
`rejectedIds.length` (ce qui a ete demande) au lieu du `count` reel
retourne par `updateMany` (ce qui a vraiment ete modifie) - un id
inexistant faisait afficher un faux succes. Decouvert en executant le
script pour de vrai avec des ids partiellement invalides.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(tech-steps): corrige un span de correction incorrect sur un highlight existant
Bug reel trouve en lancant l'application pour de vrai et en cliquant sur
un highlight existant : la correction soumise couvrait presque toute la
description au lieu du seul mot-cle cliqué (ex: [6, 56) au lieu de [6, 13)
pour "mijoter").
Cause : StepDescription.tsx capturait `start` dans un `const` par
iteration de `.map()` (correct), mais utilisait `offset` directement (la
variable mutable partagee, pas une valeur capturee) pour `end` dans le
gestionnaire onClick - une fermeture classique sur variable de boucle
encore mutee. Par le temps ou l'utilisateur clique reellement (bien apres
la fin du rendu), `offset` contient sa valeur finale (fin de la
description entiere), pas celle du segment concerne.
Corrige en capturant `end` dans un `const` au meme endroit que `start`.
Renforce aussi l'assertion e2e correspondante (recipes.ts) qui ne
verifiait auparavant que la requete avait ete faite, jamais son contenu -
elle serait passee malgre ce bug.
Verifie en conditions reelles : recette creee via l'UI, correction
soumise, span persiste verifie directement en base (start=6, end=13,
previous=simmer, corrected=grill).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* chore: ignore les telechargements Cypress (artefact de run local)
* feat(tech-steps): distingue les corrections manuelles des détections auto
Les corrections utilisateur (via TechStepCorrectionPopover) sont
désormais écrites directement dans StepTechStep, avec une colonne
`source` ("auto" | "manual") qui les distingue des matches du
classifieur NLP :
- Migration `step_tech_step_source` ajoutant `source` (défaut "auto")
- `applyManualCorrection`/`renumberStepTechSteps` dans
recipe-tech-step-correction.service.ts : une correction met à jour
ou crée l'entrée StepTechStep concernée (source "manual"), la
réponse de l'endpoint inclut désormais le techSteps à jour du step
(SubmitTechStepCorrectionResult), pas seulement l'audit de
correction
- backfill-tech-steps.ts préserve les entrées "manual" existantes :
seules les entrées "auto" sont recalculées, et un nouveau match
auto chevauchant une correction manuelle est ignoré plutôt
qu'inséré en doublon — vérifié en base réelle (une correction
manuelle survit intacte à un backfill complet)
- Le front distingue visuellement les deux (StepDescription.tsx,
recipes.scss : `.step-tech-step--manual`, couleur Turmeric au lieu
de Basil), avec un tooltip "(correction manuelle)" et un indicateur
de découvrabilité de la fonctionnalité dans RecipeDetailPanel
Corrige aussi deux bugs trouvés en testant en conditions réelles :
- StepDescription.tsx : le clic sur un highlight existant lisait la
variable `offset` (mutable, partagée par la boucle) au lieu d'une
valeur capturée, envoyant un `end` erroné (fin de la description
entière au lieu du span du mot cliqué)
- backfill-tech-steps.ts : le garde `import.meta.url ===
file://${process.argv[1]}` ne matche jamais sur Windows (chemins à
antislash), le script ne faisait donc rien en exécution directe ;
remplacé par `pathToFileURL(process.argv[1]).href`
335 tests apps/api passants, 40/40 composants Cypress, 75/76 e2e
Cypress (1 flake pré-existant sans rapport, non touché ici).
* fix(worker): corrige le build Docker de tech-step-llm-worker
docker compose build tech-step-llm-worker échouait sur deux problèmes
en cascade, tous deux liés à l'isolation volontaire de ce service hors
du monorepo pnpm (seul son propre package.json/tsconfig.json est copié
dans son contexte de build) :
- pnpm install --ignore-workspace --frozen-lockfile échouait
(ERR_PNPM_IGNORED_BUILDS) : sans "packageManager" dans son
package.json, corepack télécharge le pnpm le plus récent
(11.22.0), qui a durci en erreur bloquante ce qui n'était qu'un
avertissement sur les builds de dépendances ignorés
(esbuild/node-llama-cpp). Le reste du repo est épargné parce que
apps/api/Dockerfile copie le package.json racine, qui pinne déjà
pnpm@10.12.4 — ce pin ne pouvait pas atteindre ce service isolé.
Fixé en pinnant la même version ici.
- tsc échouait ensuite (TS5083 puis erreurs en cascade dans les .d.ts
de node-llama-cpp) : tsconfig.json de ce service extends le
tsconfig.base.json racine (skipLibCheck notamment), jamais copié
dans le contexte de build. Fixé en le copiant avant tsconfig.json.
Vérifié : `docker compose build tech-step-llm-worker` complet en local.
* fix(tech-steps): empêche le contexte d'un match d'avaler une correction manuelle voisine
La correction manuelle ne s'affichait pas quand elle portait sur du texte
qui n'était pas une technique à l'origine — reproduit en live : une
description avec un seul match auto-détecté ("mijoter") voit son
contexte de clause s'étendre sur toute la description dès que
splitIntoClauses (tech-step-matcher.ts) n'a trouvé qu'un seul candidat
NER (le cas courant), même quand ce candidat n'a aucun rapport avec le
reste du texte. splitDescriptionByTechSteps avançait alors son curseur
jusqu'à la fin de ce contexte large, ce qui faisait purement et
simplement disparaître (silencieusement, sans erreur) toute correction
manuelle ajoutée plus loin dans la même description — un mot pourtant
sans aucun rapport avec la technique auto-détectée.
Le contexte d'un match est purement cosmétique (StepDescription.tsx le
rend identique à du texte brut depuis que sa mise en valeur dédiée a
été désactivée) et ne doit donc jamais coûter son propre highlight à
un *autre* match. splitDescriptionByTechSteps distingue maintenant
deux notions : le chevauchement entre les spans *keyword* stricts de
deux entrées (toujours un vrai conflit, l'entrée la plus tardive est
toujours ignorée, comportement inchangé) et le chevauchement du
contexte *cosmétique* d'une entrée sur le keyword d'une autre (jamais
un vrai conflit désormais : le contexte est simplement rogné pour
laisser la place, plutôt que l'entrée voisine entière étant abandonnée).
Vérifié en conditions réelles (Docker) : une correction manuelle sur
"materiel" dans "Faire mijoter la sauce, puis ranger le materiel."
s'affiche maintenant correctement à côté du highlight auto "mijoter",
et survit à un rechargement complet de la page.
Nouveau test de régression dans highlight-tech-steps.cy.tsx
reproduisant exactement ce cas ; les 18 tests du fichier (dont tous
les cas de contexte/malformation déjà couverts) passent toujours.
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
263 lines
8.5 KiB
TypeScript
263 lines
8.5 KiB
TypeScript
/**
|
|
* 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).
|
|
*
|
|
* Deliberately *not* reusing `TECH_STEP_TRAINING_DATA`'s own `utterances`
|
|
* verbatim — scoring the classifier against the exact sentences it was
|
|
* trained on would measure memorization, not generalization. Every
|
|
* description below is original phrasing; where a case still needs to name
|
|
* a technique's own verb to be labeled with confidence (most of them, see
|
|
* this file's own limits below), it's at least a different sentence shape
|
|
* than anything in the training corpus.
|
|
*
|
|
* `expectedKeys` is a multiset in reading order (see
|
|
* `TechStepEvalOutcome`'s doc comment in `tech-step-evaluator.ts` for why
|
|
* order isn't scored but repetition is) of `TechStep.key`s — resolved to
|
|
* real DB ids and back by `tech-step-eval-runner.ts`, this file only ever
|
|
* deals in stable keys so it doesn't need DB access to author or read.
|
|
*
|
|
* Known limit of this dataset, confirmed against a real run (see
|
|
* `MIN_OVERALL_F1`'s own doc comment, `tech-step-eval-runner.ts`): most
|
|
* cases anchor on a technique's own registered synonym, but `_classifyClause`
|
|
* only falls back to that anchor when the intent classifier's own score is
|
|
* *below* `CONFIDENCE_THRESHOLD` — a confidently *wrong* whole-clause
|
|
* classification (e.g. "Blanchissez les haricots verts..." scoring
|
|
* confidently as `peel` despite the correct `blanch` anchor) overrides the
|
|
* anchor just as readily as a confidently *right* one does, so this
|
|
* dataset genuinely does measure real classifier failures, not just a
|
|
* synthetic floor. A handful of such real mismatches are expected and
|
|
* intentionally left uncorrected here (see `MIN_OVERALL_F1`'s doc comment)
|
|
* — fixing the classifier's actual behavior on them is corpus work for a
|
|
* future change, not something to hide by loosening this dataset's own
|
|
* expectations to match whatever it currently outputs.
|
|
*/
|
|
|
|
export interface TechStepEvalCase {
|
|
description: string;
|
|
locale: "fr" | "en";
|
|
expectedKeys: string[];
|
|
}
|
|
|
|
export const TECH_STEP_EVAL_DATASET: TechStepEvalCase[] = [
|
|
// --- One straightforward case per technique (fr), covering all 26 ---
|
|
{
|
|
description: "Faites cuire les pâtes al dente dans une grande casserole d'eau bien salée.",
|
|
locale: "fr",
|
|
expectedKeys: ["cook"],
|
|
},
|
|
{
|
|
description: "Faites bouillir l'eau dans une grande casserole avant d'y plonger les pâtes.",
|
|
locale: "fr",
|
|
expectedKeys: ["boil"],
|
|
},
|
|
{
|
|
description: "Plongez les beignets dans l'huile très chaude pour les faire frire.",
|
|
locale: "fr",
|
|
expectedKeys: ["fry"],
|
|
},
|
|
{
|
|
description: "Faites fondre le chocolat noir au bain-marie en remuant.",
|
|
locale: "fr",
|
|
expectedKeys: ["melt"],
|
|
},
|
|
{
|
|
description: "Déglacez la casserole avec un trait de vinaigre balsamique.",
|
|
locale: "fr",
|
|
expectedKeys: ["deglaze"],
|
|
},
|
|
{
|
|
description: "Laissez frémir la sauce tomate vingt minutes à couvert.",
|
|
locale: "fr",
|
|
expectedKeys: ["simmer"],
|
|
},
|
|
{
|
|
description: "Faites rôtir la volaille entière sur la broche du four.",
|
|
locale: "fr",
|
|
expectedKeys: ["roast"],
|
|
},
|
|
{
|
|
description: "Faites griller les brochettes de poulet quelques minutes de chaque côté.",
|
|
locale: "fr",
|
|
expectedKeys: ["grill"],
|
|
},
|
|
{
|
|
description: "Faites sauter les champignons à feu vif dans une poêle très chaude.",
|
|
locale: "fr",
|
|
expectedKeys: ["panFry"],
|
|
},
|
|
{
|
|
description: "Blanchissez les haricots verts trois minutes avant de les refroidir.",
|
|
locale: "fr",
|
|
expectedKeys: ["blanch"],
|
|
},
|
|
{
|
|
description: "Laissez mariner les brochettes de poulet deux heures au frais.",
|
|
locale: "fr",
|
|
expectedKeys: ["marinate"],
|
|
},
|
|
{
|
|
description: "Hachez grossièrement le persil frais avant de le parsemer.",
|
|
locale: "fr",
|
|
expectedKeys: ["chop"],
|
|
},
|
|
{
|
|
description: "Épluchez les carottes avant de les couper en rondelles.",
|
|
locale: "fr",
|
|
expectedKeys: ["peel"],
|
|
},
|
|
{
|
|
description: "Émincez finement l'échalote pour la vinaigrette.",
|
|
locale: "fr",
|
|
expectedKeys: ["mince"],
|
|
},
|
|
{
|
|
description: "Mélangez la farine, le sucre et les œufs dans un grand saladier.",
|
|
locale: "fr",
|
|
expectedKeys: ["mix"],
|
|
},
|
|
{
|
|
description: "Fouettez énergiquement la crème jusqu'à ce qu'elle épaississe.",
|
|
locale: "fr",
|
|
expectedKeys: ["whisk"],
|
|
},
|
|
{
|
|
description: "Incorporez délicatement la farine tamisée à la préparation.",
|
|
locale: "fr",
|
|
expectedKeys: ["foldIn"],
|
|
},
|
|
{
|
|
description: "Réservez la pâte au réfrigérateur pendant que vous préparez la garniture.",
|
|
locale: "fr",
|
|
expectedKeys: ["setAside"],
|
|
},
|
|
{
|
|
description: "Assaisonnez le poisson avec du sel, du poivre et un filet de citron.",
|
|
locale: "fr",
|
|
expectedKeys: ["season"],
|
|
},
|
|
{
|
|
description: "Égouttez soigneusement le riz dans une passoire fine.",
|
|
locale: "fr",
|
|
expectedKeys: ["drain"],
|
|
},
|
|
{
|
|
description:
|
|
"Faites dorer les morceaux de veau sur toutes leurs faces avant de mouiller avec le bouillon.",
|
|
locale: "fr",
|
|
expectedKeys: ["brown"],
|
|
},
|
|
{
|
|
description: "Laissez reposer la viande dix minutes avant de la trancher.",
|
|
locale: "fr",
|
|
expectedKeys: ["rest"],
|
|
},
|
|
{
|
|
description: "Préchauffez le four à 200 degrés avant d'y glisser le gratin.",
|
|
locale: "fr",
|
|
expectedKeys: ["preheat"],
|
|
},
|
|
{
|
|
description: "Enfournez la tarte pendant trente-cinq minutes jusqu'à ce qu'elle soit dorée.",
|
|
locale: "fr",
|
|
expectedKeys: ["bake"],
|
|
},
|
|
{
|
|
description: "Dressez harmonieusement les légumes autour de la pièce de viande.",
|
|
locale: "fr",
|
|
expectedKeys: ["plate"],
|
|
},
|
|
{
|
|
description: "Nappez le fond du moule d'une fine couche de caramel.",
|
|
locale: "fr",
|
|
expectedKeys: ["coat"],
|
|
},
|
|
|
|
// --- English coverage (same technique verbs, distinct sentences) ---
|
|
{
|
|
description: "Simmer the stock gently for forty minutes, skimming occasionally.",
|
|
locale: "en",
|
|
expectedKeys: ["simmer"],
|
|
},
|
|
{
|
|
description: "Peel the potatoes and rinse them under cold water.",
|
|
locale: "en",
|
|
expectedKeys: ["peel"],
|
|
},
|
|
{
|
|
description: "Whisk the eggs with a pinch of salt until frothy.",
|
|
locale: "en",
|
|
expectedKeys: ["whisk"],
|
|
},
|
|
{
|
|
description: "Season the soup generously with black pepper before serving.",
|
|
locale: "en",
|
|
expectedKeys: ["season"],
|
|
},
|
|
{
|
|
description: "Make sure the chicken is cooked through before serving.",
|
|
locale: "en",
|
|
expectedKeys: ["cook"],
|
|
},
|
|
|
|
// --- Multi-technique sentences, in reading order ---
|
|
{
|
|
description: "Préchauffez le four, puis faites rôtir le poulet pendant une heure.",
|
|
locale: "fr",
|
|
expectedKeys: ["preheat", "roast"],
|
|
},
|
|
{
|
|
description: "Faites revenir les oignons, puis déglacez la poêle avec du vin blanc.",
|
|
locale: "fr",
|
|
expectedKeys: ["brown", "deglaze"],
|
|
},
|
|
{
|
|
description:
|
|
"Faites cuire les légumes à la vapeur, puis assaisonnez-les avec des herbes fraîches.",
|
|
locale: "fr",
|
|
expectedKeys: ["cook", "season"],
|
|
},
|
|
{
|
|
description:
|
|
"Émincez l'oignon, faites-le suer, puis mouillez avec le bouillon et laissez mijoter.",
|
|
locale: "fr",
|
|
expectedKeys: ["mince", "simmer"],
|
|
},
|
|
|
|
// --- No technique mentioned at all ---
|
|
{
|
|
description: "Répartissez les convives autour de la table avant de commencer le repas.",
|
|
locale: "fr",
|
|
expectedKeys: [],
|
|
},
|
|
{
|
|
description: "Rangez les couverts propres dans le tiroir de la cuisine.",
|
|
locale: "fr",
|
|
expectedKeys: [],
|
|
},
|
|
{
|
|
description: "Take the dishes and glasses out of the cupboard.",
|
|
locale: "en",
|
|
expectedKeys: [],
|
|
},
|
|
|
|
// --- 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).
|
|
{
|
|
description: "This recipe calls for two tablespoons of brown sugar.",
|
|
locale: "en",
|
|
expectedKeys: [],
|
|
},
|
|
// `rest`'s EN synonyms are anchored phrases ("let it rest"/"resting
|
|
// for"...), not bare "rest" — so a sentence using the word in its
|
|
// "remainder" sense must not anchor `rest` at all.
|
|
{
|
|
description: "There is no time to rest before the guests arrive.",
|
|
locale: "en",
|
|
expectedKeys: [],
|
|
},
|
|
];
|