From 5d4ed0dffb19a1b0f53724b2d5a6ccc5f0f66782 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 4 Sep 2026 23:53:14 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(recipes):=20permet=20de=20corriger=20l?= =?UTF-8?q?a=20temp=C3=A9rature=20d'un=20tech-step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jusqu'ici, submitTechStepCorrectionSchema (POST /recipes/:id/steps/:stepId/ corrections) acceptait ingredients/utensils pour compléter les métadonnées d'une technique corrigée, mais aucun champ temperatures — StepTechStepTemperature n'était jamais écrit que par toStepTechStepCreateInputs, lors d'un create/update complet de recette. La température ajoutée en #22 n'avait jamais eu son propre circuit de correction manuelle. - packages/shared/src/schemas/recipe.ts : nouveau manualStepTechStepTemperatureInputSchema (celsius/gasMark/qualitative mutuellement exclusifs — exactement un des trois — même span start/end que ingredients/utensils). Le champ temperatures rejoint ingredients/ utensils dans le même lot combiné (les trois voyagent ensemble : dès que l'un est présent, les trois sont résolus, un absent devient [] donc effacé — jamais une fusion partielle catégorie par catégorie). - recipe-tech-step-correction.service.ts : applyManualCorrection écrit désormais StepTechStepTemperature (delete+recreate, même logique "le manuel remplace tout" que les deux autres tables). raw n'est jamais envoyé par le client — dérivé de description.slice(start, end), pour ne pas faire confiance à une copie cliente qui pourrait diverger du texte réel. loadVisibleStepOrThrow retourne désormais le texte complet de la description (pas seulement sa longueur), nécessaire pour cette dérivation. - specs/backend-architecture.md + specs/batch-cooking-modele.md : les deux routes de correction et step_tech_step_temperature n'étaient documentées nulle part — comblé au passage. - Tests Mocha ajoutés (celsius/gasMark/qualitative, rejet si zéro ou plusieurs champs renseignés, remplacement sans accumulation, dérivation de raw, span hors limites). Périmètre : back-end uniquement (voir la maquette proposée précédemment). Le popover de correction (apps/web) ne sera mis à jour que dans une PR suivante, empilée sur celle-ci. Vérifié : pnpm lint + build (shared, api) clean. Mocha non exécuté — pas de Postgres/Docker accessible dans cette session (ni Bash ni PowerShell). Co-Authored-By: Claude Sonnet 5 --- .../recipe-tech-step-correction.service.ts | 110 ++++++++--- .../recipe-tech-step-correction.test.ts | 185 ++++++++++++++++++ packages/shared/src/schemas/recipe.ts | 75 +++++-- specs/backend-architecture.md | 18 ++ specs/batch-cooking-modele.md | 25 ++- 5 files changed, 367 insertions(+), 46 deletions(-) diff --git a/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts b/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts index 27aa906..a9e4319 100644 --- a/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts +++ b/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts @@ -32,22 +32,24 @@ const correctionInclude = { } satisfies Prisma.StepTechStepCorrectionInclude; /** - * Loads `stepId`'s current `description` length (the only thing a - * correction needs from the step itself), or throws — `404 STEP_NOT_FOUND` - * if no such step exists, or if it exists but doesn't belong to `recipeId` - * (the route's own `:id`/`:stepId` nesting is meaningless otherwise — a - * request naming a real step under the wrong recipe should look identical - * to naming one that doesn't exist, same "don't leak which part was wrong" - * posture `assertRecipeVisible` already has for visibility). Otherwise - * whatever {@link assertRecipeVisible} throws (`404 RECIPE_NOT_FOUND`, - * never `403`) if the recipe exists but isn't visible to the viewer. + * Loads `stepId`'s current `description` (a correction needs its full text + * now, not just its length — see {@link submitTechStepCorrection}'s + * `raw` derivation for a manual temperature mention), or throws — + * `404 STEP_NOT_FOUND` if no such step exists, or if it exists but doesn't + * belong to `recipeId` (the route's own `:id`/`:stepId` nesting is + * meaningless otherwise — a request naming a real step under the wrong + * recipe should look identical to naming one that doesn't exist, same + * "don't leak which part was wrong" posture `assertRecipeVisible` already + * has for visibility). Otherwise whatever {@link assertRecipeVisible} + * throws (`404 RECIPE_NOT_FOUND`, never `403`) if the recipe exists but + * isn't visible to the viewer. */ async function loadVisibleStepOrThrow( recipeId: number, stepId: number, viewerId: number, viewerHouseId: number | null, -): Promise<{ id: number; descriptionLength: number }> { +): Promise<{ id: number; description: string }> { try { const step = await prisma.step.findUnique({ where: { id: stepId }, @@ -57,7 +59,7 @@ async function loadVisibleStepOrThrow( throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`); } await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId); - return { id: step.id, descriptionLength: step.description.length }; + return { id: step.id, description: step.description }; } catch (err) { throw err; // see recipe.service.ts's equivalent catch comment } @@ -208,6 +210,21 @@ interface ManualUtensilMention { start: number; end: number; } +/** + * Same as {@link ManualIngredientMention}, for a temperature — `raw` is + * already resolved by the caller ({@link submitTechStepCorrection}, which + * has the step's full `description` text to slice) rather than trusted + * from the client; see `manualStepTechStepTemperatureInputSchema`'s own + * doc comment (`packages/shared`) for why. + */ +interface ManualTemperatureMention { + celsius: number | null; + gasMark: number | null; + qualitative: "low" | "medium" | "high" | null; + raw: string; + start: number; + end: number; +} /** * Applies a correction's *effect* on `stepId`'s real `StepTechStep` @@ -233,16 +250,16 @@ interface ManualUtensilMention { * * `metadata`, when given (only ever alongside a real `correctedTechStepId` * — enforced by `submitTechStepCorrectionSchema`, not re-checked here), - * replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil` row on - * this occurrence — `source: "auto"` (the classifier's own detection) and - * any earlier `"manual"` set alike — with the newly-submitted one. This is - * "le manuel remplace tout" (confirmed with the user): the resolved - * `order` this technique ends up at (whichever branch above produced it) - * is the same `techStepOrder` both metadata tables key on, so the same - * `deleteMany` + `createMany` pair below is correct whether this call just - * updated an existing row (which may already carry auto-detected - * metadata) or created a brand new one (nothing to delete yet — a no-op - * `deleteMany`, not a special case). + * replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil`/ + * `StepTechStepTemperature` row on this occurrence — `source: "auto"` (the + * classifier's own detection) and any earlier `"manual"` set alike — with + * the newly-submitted one. This is "le manuel remplace tout" (confirmed + * with the user): the resolved `order` this technique ends up at + * (whichever branch above produced it) is the same `techStepOrder` all + * three metadata tables key on, so the same `deleteMany` + `createMany` + * pair below is correct whether this call just updated an existing row + * (which may already carry auto-detected metadata) or created a brand new + * one (nothing to delete yet — a no-op `deleteMany`, not a special case). * * Runs inside the same transaction {@link submitTechStepCorrection} uses * for the audit-trail insert, so a request never leaves any of these @@ -255,7 +272,11 @@ async function applyManualCorrection( span: { start: number; end: number }, previousTechStepId: number | null, correctedTechStepId: number | null, - metadata?: { ingredients: ManualIngredientMention[]; utensils: ManualUtensilMention[] }, + metadata?: { + ingredients: ManualIngredientMention[]; + utensils: ManualUtensilMention[]; + temperatures: ManualTemperatureMention[]; + }, ): Promise { const existing = await tx.stepTechStep.findMany({ where: { stepId } }); @@ -329,6 +350,22 @@ async function applyManualCorrection( })), }); } + await tx.stepTechStepTemperature.deleteMany({ where: { stepId, techStepOrder: order } }); + if (metadata.temperatures.length > 0) { + await tx.stepTechStepTemperature.createMany({ + data: metadata.temperatures.map((temperature) => ({ + stepId, + techStepOrder: order, + celsius: temperature.celsius, + gasMark: temperature.gasMark, + qualitative: temperature.qualitative, + raw: temperature.raw, + start: temperature.start, + end: temperature.end, + source: "manual", + })), + }); + } } } else if (target) { await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } }); @@ -366,10 +403,11 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr * @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see * {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if * `start`/`end` (the correction's own span, or any of - * `input.ingredients`/`input.utensils`' own spans) fall outside the - * step's current `description` (it may have been edited since the user - * last saw it). `404 TECH_STEP_NOT_FOUND`/`404 INGREDIENT_NOT_FOUND`/ - * `404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND` if any referenced id + * `input.ingredients`/`input.utensils`/`input.temperatures`' own spans) + * fall outside the step's current `description` (it may have been + * edited since the user last saw it). `404 TECH_STEP_NOT_FOUND`/ + * `404 INGREDIENT_NOT_FOUND`/`404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND` + * if any referenced id * doesn't exist. */ export async function submitTechStepCorrection( @@ -381,18 +419,20 @@ export async function submitTechStepCorrection( ): Promise { try { const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId); + const descriptionLength = step.description.length; const spans = [ { start: input.start, end: input.end }, ...(input.ingredients ?? []), ...(input.utensils ?? []), + ...(input.temperatures ?? []), ]; for (const span of spans) { - if (span.start >= step.descriptionLength || span.end > step.descriptionLength) { + if (span.start >= descriptionLength || span.end > descriptionLength) { throw new HttpError( 400, ErrorCode.INVALID_CORRECTION_SPAN, - `Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`, + `Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${descriptionLength})`, ); } } @@ -428,7 +468,9 @@ export async function submitTechStepCorrection( { start: input.start, end: input.end }, input.previousTechStepId ?? null, input.correctedTechStepId ?? null, - input.ingredients === undefined && input.utensils === undefined + input.ingredients === undefined && + input.utensils === undefined && + input.temperatures === undefined ? undefined : { ingredients: (input.ingredients ?? []).map((ingredient) => ({ @@ -443,6 +485,16 @@ export async function submitTechStepCorrection( start: utensil.start, end: utensil.end, })), + // `raw` is derived here (not sent by the client) — see + // `ManualTemperatureMention`'s own doc comment. + temperatures: (input.temperatures ?? []).map((temperature) => ({ + celsius: temperature.celsius ?? null, + gasMark: temperature.gasMark ?? null, + qualitative: temperature.qualitative ?? null, + raw: step.description.slice(temperature.start, temperature.end), + start: temperature.start, + end: temperature.end, + })), }, ); 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 acebbff..757b2c3 100644 --- a/apps/api/test/recipe/recipe-tech-step-correction.test.ts +++ b/apps/api/test/recipe/recipe-tech-step-correction.test.ts @@ -518,6 +518,191 @@ describe("Recipe tech-step corrections", () => { }); }); + describe("POST /recipes/:id/steps/:stepId/corrections — temperature metadata", () => { + it("attaches a manually-selected celsius temperature, deriving raw from the step's own description", async () => { + const { agent, profileId } = await signup(); + const description = "Préchauffez à 180°C."; + const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description); + const preheatId = await techStepId("preheat"); + + const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({ + start: 0, + end: 11, + correctedTechStepId: preheatId, + temperatures: [{ celsius: 180, start: 14, end: 19 }], + }); + + expect(res.status).to.equal(201); + expect(res.body.techSteps).to.deep.equal([ + { + techStep: { id: preheatId, key: "preheat" }, + start: 0, + end: 11, + source: "manual", + ingredients: [], + utensils: [], + temperatures: [ + { + celsius: 180, + gasMark: null, + qualitative: null, + // Not sent by the client — derived server-side from the span + // against the step's real description (see + // `ManualTemperatureMention`'s doc comment). + raw: "180°C", + start: 14, + end: 19, + source: "manual", + }, + ], + }, + ]); + }); + + it("attaches a manually-selected qualitative temperature", async () => { + const { agent, profileId } = await signup(); + const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); + const simmerId = await techStepId("simmer"); + + const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({ + start: 6, + end: 13, + correctedTechStepId: simmerId, + temperatures: [{ qualitative: "low", start: 0, end: 5 }], + }); + + expect(res.status).to.equal(201); + expect(res.body.techSteps[0].temperatures).to.deep.equal([ + { + celsius: null, + gasMark: null, + qualitative: "low", + raw: "Faire", + start: 0, + end: 5, + source: "manual", + }, + ]); + }); + + it("attaches a manually-selected gas-mark temperature", async () => { + const { agent, profileId } = await signup(); + const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); + const simmerId = await techStepId("simmer"); + + const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({ + start: 6, + end: 13, + correctedTechStepId: simmerId, + temperatures: [{ gasMark: 6, start: 0, end: 5 }], + }); + + expect(res.status).to.equal(201); + expect(res.body.techSteps[0].temperatures[0]).to.deep.equal({ + celsius: null, + gasMark: 6, + qualitative: null, + raw: "Faire", + start: 0, + end: 5, + source: "manual", + }); + }); + + it("rejects a temperature with more than one of celsius/gasMark/qualitative set with 400 VALIDATION_ERROR", async () => { + const { agent, profileId } = await signup(); + const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); + + const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({ + start: 6, + end: 13, + correctedTechStepId: await techStepId("simmer"), + temperatures: [{ celsius: 180, qualitative: "high", start: 0, end: 5 }], + }); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + }); + + it("rejects a temperature with none of celsius/gasMark/qualitative set with 400 VALIDATION_ERROR", async () => { + const { agent, profileId } = await signup(); + const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); + + const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({ + start: 6, + end: 13, + correctedTechStepId: await techStepId("simmer"), + temperatures: [{ start: 0, end: 5 }], + }); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + }); + + it("replaces both auto-detected and previously-manual temperatures on the same occurrence — never accumulates", async () => { + const { agent, profileId } = await signup(); + const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); + const simmerId = await techStepId("simmer"); + const boilId = await techStepId("boil"); + + await agent + .post(`/recipes/${recipeId}/steps/${stepId}/corrections`) + .send({ start: 6, end: 13, correctedTechStepId: simmerId }); + // Simulate an auto-detected temperature already sitting on the + // occurrence, exactly as `toStepTechStepCreateInputs` would have + // written one at save time. + await prisma.stepTechStepTemperature.create({ + data: { + stepId, + techStepOrder: 0, + celsius: 100, + raw: "100°C", + start: 0, + end: 5, + source: "auto", + }, + }); + + const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({ + start: 6, + end: 13, + previousTechStepId: simmerId, + correctedTechStepId: boilId, + temperatures: [{ gasMark: 6, start: 0, end: 5 }], + }); + + expect(res.status).to.equal(201); + expect(res.body.techSteps).to.have.length(1); + expect(res.body.techSteps[0].temperatures).to.deep.equal([ + { + celsius: null, + gasMark: 6, + qualitative: null, + raw: "Faire", + start: 0, + end: 5, + source: "manual", + }, + ]); + }); + + it("rejects a temperature span past the end of the step's description with 400 INVALID_CORRECTION_SPAN", async () => { + const { agent, profileId } = await signup(); + const description = "Court."; + const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description); + + const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({ + start: 0, + end: description.length, + correctedTechStepId: await techStepId("simmer"), + temperatures: [{ celsius: 180, start: 0, end: description.length + 10 }], + }); + + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.INVALID_CORRECTION_SPAN); + }); + }); + describe("GET /recipes/:id/steps/:stepId/corrections", () => { it("returns every correction submitted for the step, most recent first", async () => { const { agent, profileId } = await signup(); diff --git a/packages/shared/src/schemas/recipe.ts b/packages/shared/src/schemas/recipe.ts index c162264..9969b3d 100644 --- a/packages/shared/src/schemas/recipe.ts +++ b/packages/shared/src/schemas/recipe.ts @@ -175,6 +175,48 @@ const manualStepTechStepUtensilInputSchema = z path: ["end"], }); +/** + * A temperature mention the user points at while correcting a technique — + * same `start`/`end` span convention as + * {@link manualStepTechStepIngredientInputSchema}/ + * {@link manualStepTechStepUtensilInputSchema}. Exactly one of + * `celsius`/`gasMark`/`qualitative` must be set: the same mutual-exclusivity + * invariant the auto-detection pipeline already produces (see + * `StepTechStepTemperatureView`'s doc comment, `packages/shared/src/types/recipe.ts`) + * — a manual entry can't represent "both 180 °C and Thermostat 6" any more + * than a detected one can. + * + * No `raw` field here, unlike `StepTechStepTemperatureView`/the Prisma + * column: an auto-detected mention's `raw` is the exact text a regex + * matched, but a manual one is built from a structured value the user + * picked (a number, or a qualitative level), not matched text — the + * service derives it from the step's own `description.slice(start, end)` + * instead of trusting a client-supplied copy that could drift from what's + * actually at that span. + */ +const manualStepTechStepTemperatureInputSchema = z + .object({ + celsius: z.number().nullable().optional(), + gasMark: z.number().int().positive().nullable().optional(), + qualitative: z.enum(["low", "medium", "high"]).nullable().optional(), + start: z.number().int().nonnegative(), + end: z.number().int().nonnegative(), + }) + .refine((temperature) => temperature.end > temperature.start, { + message: "end must be greater than start", + path: ["end"], + }) + .refine( + (temperature) => + [temperature.celsius, temperature.gasMark, temperature.qualitative].filter( + (value) => value !== null && value !== undefined, + ).length === 1, + { + message: "exactly one of celsius/gasMark/qualitative is required", + path: ["celsius"], + }, + ); + /** * Payload accepted by `POST /recipes/:id/steps/:stepId/corrections` — a * user asserting what technique a `[start, end)` span of a step's @@ -188,16 +230,22 @@ const manualStepTechStepUtensilInputSchema = z * `description` length to validate `start`/`end` against, which this shape * alone can't see. * - * `ingredients`/`utensils` let the user attach metadata to the technique - * they're asserting (`correctedTechStepId`), same `source: "manual"` - * distinction the technique itself gets. **Omitted (`undefined`) means - * "leave whatever metadata already exists on this occurrence alone" — - * an explicit array, even `[]`, means "this is now the complete set, - * replace everything that was there" (auto-detected included; see - * `applyManualCorrection`'s own doc comment). This is why neither field - * has a `.default([])`: that would silently turn every plain relabel into - * a metadata wipe.** Only meaningful alongside a real `correctedTechStepId` - * — enforced by this schema's own refine below, since there's no live + * `ingredients`/`utensils`/`temperatures` let the user attach metadata to + * the technique they're asserting (`correctedTechStepId`), same + * `source: "manual"` distinction the technique itself gets. The three + * travel as **one combined bundle, not three independent ones**: **omitted + * (`undefined`) on all three at once** means "leave whatever metadata + * already exists on this occurrence alone" — as soon as *any one* of them + * is present, all three are resolved (an absent one defaults to `[]`) and + * **replace everything that was there** for that category, auto-detected + * included (see `applyManualCorrection`'s own doc comment). This is why + * none of the three has a `.default([])`: that would silently turn every + * plain relabel into a metadata wipe. A caller touching just one category + * (e.g. adding a temperature) must still resend the other two's current + * full state alongside it — same contract `TechStepCorrectionPopover.tsx` + * already follows for ingredients/utensils, just extended to a third + * category. Only meaningful alongside a real `correctedTechStepId` — + * enforced by this schema's own refine below, since there's no live * `StepTechStep` row to attach to otherwise (removing a match, or a * request with neither id set). */ @@ -209,6 +257,7 @@ export const submitTechStepCorrectionSchema = z correctedTechStepId: z.number().int().positive().nullable().optional(), ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(), utensils: z.array(manualStepTechStepUtensilInputSchema).optional(), + temperatures: z.array(manualStepTechStepTemperatureInputSchema).optional(), }) .refine((input) => input.end > input.start, { message: "end must be greater than start", @@ -224,10 +273,12 @@ export const submitTechStepCorrectionSchema = z ) .refine( (input) => - (input.ingredients === undefined && input.utensils === undefined) || + (input.ingredients === undefined && + input.utensils === undefined && + input.temperatures === undefined) || (input.correctedTechStepId ?? null) !== null, { - message: "ingredients/utensils require a correctedTechStepId to attach to", + message: "ingredients/utensils/temperatures require a correctedTechStepId to attach to", path: ["correctedTechStepId"], }, ); diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index f431a7e..bda0b23 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -587,6 +587,24 @@ ne persiste rien. | `PATCH /recipes/:id` | `updateRecipe` (remplacement complet, pas de fusion partielle) | | `DELETE /recipes/:id` | `deleteRecipe` (409 `RECIPE_IN_USE` si référencée par un `PlanningItem`) | | `POST`/`DELETE /recipes/:id/favorite` | `addFavorite`/`removeFavorite` (idempotents) | +| `POST /recipes/:id/steps/:stepId/corrections` | `submitTechStepCorrection` (`recipe-tech-step-correction.service.ts`) — ouvert à tout viewer qui voit la recette, pas seulement son auteur | +| `GET /recipes/:id/steps/:stepId/corrections` | `listTechStepCorrections` — historique brut des corrections soumises pour l'étape | + +**Corrections de tech-step** — une correction porte sur un `[start, end)` de +`Step.description` (`previousTechStepId`/`correctedTechStepId`, voir +[batch-cooking-modele.md](./batch-cooking-modele.md#step-step-et-techniques-détectées)). +Elle enregistre systématiquement une ligne d'audit `StepTechStepCorrection` +(jamais modifiée ensuite) **et** applique l'effet immédiatement sur la +séquence réelle `StepTechStep` de l'étape — pas seulement une suggestion en +attente pour `services/tech-step-llm-worker`. `ingredients`/`utensils`/ +`temperatures` peuvent accompagner un `correctedTechStepId` réel pour +attacher/remplacer les métadonnées de cette occurrence ; les trois voyagent +comme **un seul lot** — dès que l'une est présente dans la requête, les +trois sont résolues (une absente devient `[]`, donc effacée), jamais une +fusion partielle catégorie par catégorie. `temperatures[]` exige +exactement un de `celsius`/`gasMark`/`qualitative` par entrée (même +contrainte que la détection automatique) ; son `raw` n'est jamais envoyé +par le client — dérivé côté service de `description.slice(start, end)`. **Visibilité** (`RecipeVisibility` — `PERSONAL`/`HOUSE`/`PUBLIC`, voir [batch-cooking-modele.md](./batch-cooking-modele.md)) contrôle uniquement la diff --git a/specs/batch-cooking-modele.md b/specs/batch-cooking-modele.md index 072ca4c..129f43e 100644 --- a/specs/batch-cooking-modele.md +++ b/specs/batch-cooking-modele.md @@ -379,13 +379,28 @@ dans `Step.description`, utilisé pour le surlignage côté web Chaque `step_tech_step` porte en plus les métadonnées trouvées dans sa propre clause : `step_tech_step_ingredient` (ingrédient résolu contre le catalogue `ingredients` existant, `quantity`/`unit_id` optionnels quand une quantité a -pu être extraite juste avant la mention) et `step_tech_step_utensil` +pu être extraite juste avant la mention), `step_tech_step_utensil` (ustensile résolu contre un nouveau catalogue `utensil`, même forme -minimale `id`/`key` que `tech_step` — voir +minimale `id`/`key` que `tech_step`), et `step_tech_step_temperature` +(température de la clause — `celsius`/`gas_mark`/`qualitative` +mutuellement exclusifs, un seul non-null par ligne ; `raw` conserve le +texte exact matché, ex. `"180°C"`/`"th. 6"`/`"feu doux"` ; `qualitative` +vaut `"low"`/`"medium"`/`"high"`, contrainte au niveau TypeScript +seulement — pas d'enum Postgres ici, contrairement à `AllergenKind`/ +`ThemePreference`/`UnitType` ailleurs dans ce schéma). Les trois référencent +`step_tech_step` par sa clé composite `(step_id, order)`, `onDelete: +Cascade` comme le reste de cette chaîne — voir [backend-architecture.md](./backend-architecture.md#détection-des-techniques--tech-step-matcherts) -pour comment chacun est détecté). Les deux référencent `step_tech_step` par -sa clé composite `(step_id, order)`, `onDelete: Cascade` comme le reste de -cette chaîne. +pour comment chacune est détectée. + +`source` (`"auto"` | `"manual"`, même contrainte TypeScript-seule que +`qualitative` ci-dessus) marque, sur `step_tech_step` et ses trois tables de +métadonnées, si la ligne vient du classifieur ou d'une correction manuelle +soumise par un utilisateur (`POST /recipes/:id/steps/:stepId/corrections`, +voir [backend-architecture.md](./backend-architecture.md#recipe)) — cette +route remplace intégralement les trois tables de métadonnées d'une +occurrence dès que l'une d'elles est touchée ("le manuel remplace tout"), +jamais un ajout partiel. --- -- 2.45.2 From 6c5978dd1c9bfd07a07838ffdde7f673c61e31ef Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 5 Sep 2026 00:22:01 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(recipes):=20refond=20le=20panneau=20de?= =?UTF-8?q?=20correction=20des=20m=C3=A9tadonn=C3=A9es=20de=20tech-step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suite à la maquette validée : redessine TechStepCorrectionPopover et ajoute l'édition de la température, jusqu'ici la seule des 4 métadonnées sans aucune interface (ni dans l'UI, ni côté API — voir la PR back-end empilée sous celle-ci). - StepDescription.tsx : le surlignage de température devient cliquable (un ); } @@ -263,11 +329,13 @@ export function StepDescription({