batchCooking/packages/shared/src/schemas/recipe.ts
kyuno053 92bea914e8
feat(tech-steps): fiabilise la détection des tech steps (corpus + LLM + corrections utilisateur) (#66)
* 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>
2026-08-22 15:57:57 +02:00

153 lines
8.2 KiB
TypeScript

import { z } from "zod";
// See schemas/auth.ts for the shared client/server validation rationale.
/**
* One ingredient line accepted by `POST /recipes`/`PATCH /recipes/:id`.
* `ingredientId` must reference an existing reference `Ingredient` (see
* `GET /reference/ingredients`) — there is no way to create one from here,
* ingredients are static reference data. An unknown id is rejected
* service-side with `INGREDIENT_NOT_FOUND`, not here — this schema only
* checks shape.
*/
const recipeIngredientInputSchema = z.object({
ingredientId: z.number().int().positive(),
quantity: z.number().positive("La quantité doit être positive"),
/** References a reference `Unit` row (see `GET /reference/units`) — free-text units were replaced by this closed catalog, see `Unit` in schema.prisma. An unknown id is rejected service-side with `UNIT_NOT_FOUND`, same posture as `ingredientId`. */
unitId: z.number().int().positive(),
});
/**
* One preparation step accepted by `POST /recipes`/`PATCH /recipes/:id`.
* `order` is deliberately not part of this shape — it's derived server-side
* from the step's position in the `steps` array, so the client (the
* step reorder UI) never has to keep an explicit order field in sync.
*/
const recipeStepInputSchema = z.object({
description: z.string().trim().min(1, "La description de l'étape est requise").max(2000),
picture: z.string().trim().url("URL invalide").nullable().optional(),
});
/**
* Who can read the recipe being created/edited — mirrors `RecipeVisibility`
* in schema.prisma. Defaults to `PERSONAL` (visible to its author only) —
* the author explicitly opens it up to `HOUSE`/`PUBLIC` if they want to
* share it, rather than the other way around.
*/
const recipeVisibilitySchema = z.enum(["PERSONAL", "HOUSE", "PUBLIC"]);
/** Payload accepted by `POST /recipes` and `PATCH /recipes/:id` (a full replace, not a partial merge — see the API's `recipe.service.ts`). */
export const createRecipeSchema = z
.object({
name: z.string().trim().min(1, "Le nom de la recette est requis").max(150),
description: z.string().trim().max(2000).nullable().optional(),
picture: z.string().trim().url("URL invalide").nullable().optional(),
/** How many portions this recipe yields as written — see `Recipe.portions` in schema.prisma. */
portions: z.number().int().positive("Le nombre de portions doit être positif"),
visibility: recipeVisibilitySchema.default("PERSONAL"),
/** `dietId`s tagged as "this recipe suits this regime" — a manual reminder, not computed from ingredients. Empty = no regime associated. */
dietIds: z.array(z.number().int().positive()),
ingredients: z.array(recipeIngredientInputSchema).min(1, "Au moins un ingrédient est requis"),
steps: z.array(recipeStepInputSchema).min(1, "Au moins une étape est requise"),
})
// `RecipeIngredient`'s primary key is `(recipeId, ingredientId)` — one row
// per ingredient per recipe (schema.prisma) — so two lines with the same
// `ingredientId` would otherwise reach `prisma.recipe.create()` and crash
// with an unhandled `P2002` unique-constraint 500, instead of the clean
// 400 every other invalid shape gets here. The manual recipe form can't
// produce this (`IngredientPicker`'s `excludeIds` hides an
// already-selected ingredient), but a source import can: two raw lines
// ("Egg Yolks", "Eggs") can independently resolve to the same catalog
// ingredient (see `RecipeImportForm.tsx`, issue #53's `matchUnit`
// fallback made this reachable in practice) — rejected here rather than
// silently summed, since two lines resolving to the same ingredient
// aren't necessarily interchangeable quantities (different units,
// different confidence in the match).
.refine(
(input) => {
const ingredientIds = input.ingredients.map((ingredient) => ingredient.ingredientId);
return new Set(ingredientIds).size === ingredientIds.length;
},
{
message: "Un même ingrédient ne peut pas apparaître plusieurs fois dans une recette",
path: ["ingredients"],
},
);
/** Inferred TS type for {@link createRecipeSchema}'s validated output. */
export type CreateRecipeInput = z.infer<typeof createRecipeSchema>;
/** `PATCH /recipes/:id` shares the exact same shape as creation — see {@link createRecipeSchema}. */
export const updateRecipeSchema = createRecipeSchema;
/** Inferred TS type for {@link updateRecipeSchema}'s validated output. */
export type UpdateRecipeInput = z.infer<typeof updateRecipeSchema>;
/**
* Which catalog tab `GET /recipes` should filter for — see
* `recipe.service.ts`'s `listRecipes` for what each value actually
* queries. No "toutes" value on purpose: every recipe visible to a viewer
* falls under exactly one of `perso`/`foyer`/`publique` (its own
* visibility), `favoris` is an orthogonal, cross-cutting filter on top.
*/
export const recipeTabSchema = z.enum(["favoris", "perso", "foyer", "publique"]);
/** Inferred TS type for {@link recipeTabSchema}'s validated output. */
export type RecipeTab = z.infer<typeof recipeTabSchema>;
/**
* A repeated query param (`?ingredientIds=1&ingredientIds=2`) arrives via
* Express/`qs` as a plain string when there's exactly one, or a string
* array when there's more than one — never a bare array for a single
* value. Normalizes both (plus the "absent" case) into `number[] |
* undefined` before the real `z.array` check runs.
*/
function queryIdArray() {
return z.preprocess(
(value) => (value === undefined ? undefined : Array.isArray(value) ? value : [value]),
z.array(z.coerce.number().int().positive()).optional(),
);
}
/** Payload accepted by `GET /recipes`'s query params — `tab` selects the catalog tab, `search` optionally filters it further by name substring, `suitableForHousehold` (the planning recipe picker's "convient à tout le foyer" toggle) further restricts to recipes that avoid every household member's declared allergens and match every member's declared regime (see `recipe.service.ts`'s `listRecipes`) — a no-op if the caller has no household. `ingredientIds`/`dietIds` (the same picker's ingredient/regime filters) further restrict to recipes carrying *every* id listed (AND, not "any of") — see {@link queryIdArray}. `z.coerce.boolean()` since query params always arrive as strings. */
export const listRecipesSchema = z.object({
tab: recipeTabSchema,
search: z.string().trim().min(1).optional(),
suitableForHousehold: z.coerce.boolean().optional(),
ingredientIds: queryIdArray(),
dietIds: queryIdArray(),
});
/** Inferred TS type for {@link listRecipesSchema}'s validated output. */
export type ListRecipesInput = z.infer<typeof listRecipesSchema>;
/**
* Payload accepted by `POST /recipes/:id/steps/:stepId/corrections` — a
* user asserting what technique a `[start, end)` span of a step's
* `description` should (or shouldn't) be tagged with. `previousTechStepId`
* is the existing match being corrected (omit/`null` when the user is
* flagging a technique the classifier missed entirely — nothing to
* correct, just to add); `correctedTechStepId` is what they assert instead
* (omit/`null` means "no technique belongs here", i.e. removing a wrong
* match). Rejecting both being absent at once happens service-side
* (`recipe-tech-step-correction.service.ts`) — needs the target step's
* `description` length to validate `start`/`end` against, which this shape
* alone can't see.
*/
export const submitTechStepCorrectionSchema = z
.object({
start: z.number().int().nonnegative(),
end: z.number().int().nonnegative(),
previousTechStepId: z.number().int().positive().nullable().optional(),
correctedTechStepId: z.number().int().positive().nullable().optional(),
})
.refine((input) => input.end > input.start, {
message: "end must be greater than start",
path: ["end"],
})
.refine(
(input) =>
(input.previousTechStepId ?? null) !== null || (input.correctedTechStepId ?? null) !== null,
{
message: "at least one of previousTechStepId/correctedTechStepId is required",
path: ["correctedTechStepId"],
},
);
/** Inferred TS type for {@link submitTechStepCorrectionSchema}'s validated output. */
export type SubmitTechStepCorrectionInput = z.infer<typeof submitTechStepCorrectionSchema>;