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).
This commit is contained in:
Nicolas 2026-08-22 12:15:47 +02:00
parent c83754a812
commit ecf236c1a4
18 changed files with 548 additions and 130 deletions

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "step_tech_step" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'auto';

View file

@ -698,14 +698,27 @@ model Step {
/// techniques from scratch (`recipe.service.ts`'s `updateRecipe` deletes /// techniques from scratch (`recipe.service.ts`'s `updateRecipe` deletes
/// and recreates every `Step`/`StepTechStep`, never a partial patch) — /// and recreates every `Step`/`StepTechStep`, never a partial patch) —
/// graceful degradation, not a permanent gap. /// graceful degradation, not a permanent gap.
///
/// `source` distinguishes a `"manual"` row — written immediately when a
/// user submits a `StepTechStepCorrection` that asserts a technique
/// (`recipe-tech-step-correction.service.ts`'s `applyManualCorrection`),
/// not just recorded as a pending suggestion — from an `"auto"` row the
/// classifier itself produced (`tech-step-matcher.ts`). Both kinds coexist
/// in the same ordered sequence; the detail view (`apps/web`) renders them
/// with a different highlight color so a viewer can tell which is which.
/// `backfillTechSteps` (`scripts/backfill-tech-steps.ts`) only ever
/// deletes/recreates `"auto"` rows — a `"manual"` row survives a
/// classifier/corpus change until a user (or a future moderation feature)
/// explicitly changes it again.
model StepTechStep { model StepTechStep {
stepId Int @map("step_id") stepId Int @map("step_id")
techStepId Int @map("tech_step_id") techStepId Int @map("tech_step_id")
order Int order Int
start Int? start Int?
end Int? end Int?
contextStart Int? @map("context_start") contextStart Int? @map("context_start")
contextEnd Int? @map("context_end") contextEnd Int? @map("context_end")
source String @default("auto")
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade) step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade) techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)

View file

@ -3,10 +3,11 @@ import {
ErrorCode, ErrorCode,
type StepTechStepCorrectionView, type StepTechStepCorrectionView,
type SubmitTechStepCorrectionInput, type SubmitTechStepCorrectionInput,
type SubmitTechStepCorrectionResult,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { assertRecipeVisible } from "./recipe.service.js"; import { assertRecipeVisible, toStepTechStepViews } from "./recipe.service.js";
/** /**
* User-submitted corrections to a step's detected techniques * User-submitted corrections to a step's detected techniques
@ -83,6 +84,126 @@ async function assertTechStepsExist(ids: number[]): Promise<void> {
} }
} }
/**
* Renumbers every one of `stepId`'s `StepTechStep` rows' `order` by
* ascending `start` (nulls-still-possible legacy rows, see that model's
* schema doc comment, sort last) the dense, reading-order 0-based
* sequence `@@id([stepId, order])` requires, regardless of whether a
* caller just inserted, updated, or deleted a row. Simpler and less
* error-prone than shifting only the affected neighbors' `order` by hand.
*
* Two passes, through a disjoint negative range first: updating straight
* into the final 0..N-1 positions in one pass risks a transient
* `(stepId, order)` collision (e.g. the row destined for `order: 0` isn't
* necessarily the one already sitting there) `order` is always `>= 0`
* in real usage, so a negative range can never collide with a live row.
*
* Exported for `scripts/backfill-tech-steps.ts` to reuse after it
* recomputes just the `"auto"` subset of a step's rows, so the combined
* `"auto"` + `"manual"` sequence still ends up in one coherent
* reading-order.
*/
export async function renumberStepTechSteps(
tx: Prisma.TransactionClient,
stepId: number,
): Promise<void> {
const rows = await tx.stepTechStep.findMany({ where: { stepId } });
const sorted = [...rows].sort(
(a, b) => (a.start ?? Number.POSITIVE_INFINITY) - (b.start ?? Number.POSITIVE_INFINITY),
);
for (const [index, row] of sorted.entries()) {
await tx.stepTechStep.update({
where: { stepId_order: { stepId, order: row.order } },
data: { order: -(index + 1) },
});
}
for (const [index] of sorted.entries()) {
await tx.stepTechStep.update({
where: { stepId_order: { stepId, order: -(index + 1) } },
data: { order: index },
});
}
}
/**
* Applies a correction's *effect* on `stepId`'s real `StepTechStep`
* sequence, immediately not just recorded as a pending suggestion for
* `services/tech-step-llm-worker` to eventually process (see
* `StepTechStepCorrection`'s schema doc comment; this is *in addition to*
* that offline feedback loop, not instead of it). `previousTechStepId`/
* `correctedTechStepId` mean exactly what they do on
* `StepTechStepCorrection` itself (`SubmitTechStepCorrectionInput`'s doc
* comment, `packages/shared`):
*
* - `correctedTechStepId` set (add or relabel): a `"manual"` row is
* written at the correction's own `[start, end)` updating the
* existing entry in place when one matching `previousTechStepId`
* overlaps this span, otherwise inserting a new one. No `contextStart`/
* `contextEnd` a correction only ever carries the tight span the user
* themselves selected/clicked, nothing wider to highlight around it.
* - `previousTechStepId` alone (remove, `correctedTechStepId: null`): the
* matching existing entry is deleted outright. A no-op if none matches
* (nothing to remove).
*
* Runs inside the same transaction {@link submitTechStepCorrection} uses
* for the audit-trail insert, so a request never leaves the two effects
* (the permanent correction record, the live sequence change) only
* partially applied.
*/
async function applyManualCorrection(
tx: Prisma.TransactionClient,
stepId: number,
span: { start: number; end: number },
previousTechStepId: number | null,
correctedTechStepId: number | null,
): Promise<void> {
const existing = await tx.stepTechStep.findMany({ where: { stepId } });
const target =
previousTechStepId !== null
? existing.find(
(row) =>
row.techStepId === previousTechStepId &&
row.start !== null &&
row.end !== null &&
row.start < span.end &&
span.start < row.end,
)
: undefined;
if (correctedTechStepId !== null) {
if (target) {
await tx.stepTechStep.update({
where: { stepId_order: { stepId, order: target.order } },
data: {
techStepId: correctedTechStepId,
start: span.start,
end: span.end,
contextStart: null,
contextEnd: null,
source: "manual",
},
});
} else {
const nextOrder = existing.reduce((max, row) => Math.max(max, row.order), -1) + 1;
await tx.stepTechStep.create({
data: {
stepId,
techStepId: correctedTechStepId,
order: nextOrder,
start: span.start,
end: span.end,
source: "manual",
},
});
}
} else if (target) {
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
}
await renumberStepTechSteps(tx, stepId);
}
function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorrectionView { function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorrectionView {
return { return {
id: correction.id, id: correction.id,
@ -100,10 +221,14 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
/** /**
* Records one correction to `stepId`'s detected techniques, submitted by * Records one correction to `stepId`'s detected techniques, submitted by
* `correctorId` see {@link SubmitTechStepCorrectionInput}'s doc comment * `correctorId`, and immediately applies its effect to the step's real
* (`packages/shared`) for what `previousTechStepId`/`correctedTechStepId` * `StepTechStep` sequence (a `"manual"`-tagged row see
* each mean. Never edited/deleted afterward (see `StepTechStepCorrection`'s * {@link applyManualCorrection}) see
* schema doc comment) this is a pure insert. * {@link SubmitTechStepCorrectionInput}'s doc comment (`packages/shared`)
* for what `previousTechStepId`/`correctedTechStepId` each mean. The audit
* record itself is never edited/deleted afterward (see
* `StepTechStepCorrection`'s schema doc comment) only the live sequence
* changes on a later correction to the same span.
* *
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` see * @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` see
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if * {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
@ -117,7 +242,7 @@ export async function submitTechStepCorrection(
input: SubmitTechStepCorrectionInput, input: SubmitTechStepCorrectionInput,
correctorId: number, correctorId: number,
viewerHouseId: number | null, viewerHouseId: number | null,
): Promise<StepTechStepCorrectionView> { ): Promise<SubmitTechStepCorrectionResult> {
try { try {
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId); const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
@ -134,19 +259,37 @@ export async function submitTechStepCorrection(
); );
await assertTechStepsExist(techStepIds); await assertTechStepsExist(techStepIds);
const created = await prisma.stepTechStepCorrection.create({ const { correction, techSteps } = await prisma.$transaction(async (tx) => {
data: { const createdCorrection = await tx.stepTechStepCorrection.create({
stepId: step.id, data: {
correctorId, stepId: step.id,
start: input.start, correctorId,
end: input.end, start: input.start,
previousTechStepId: input.previousTechStepId ?? null, end: input.end,
correctedTechStepId: input.correctedTechStepId ?? null, previousTechStepId: input.previousTechStepId ?? null,
}, correctedTechStepId: input.correctedTechStepId ?? null,
include: correctionInclude, },
include: correctionInclude,
});
await applyManualCorrection(
tx,
step.id,
{ start: input.start, end: input.end },
input.previousTechStepId ?? null,
input.correctedTechStepId ?? null,
);
const freshTechSteps = await tx.stepTechStep.findMany({
where: { stepId: step.id },
orderBy: { order: "asc" },
include: { techStep: true },
});
return { correction: createdCorrection, techSteps: freshTechSteps };
}); });
return toCorrectionView(created); return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
} catch (err) { } catch (err) {
throw err; // see loadVisibleStepOrThrow's catch comment throw err; // see loadVisibleStepOrThrow's catch comment
} }

View file

@ -132,18 +132,30 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
* existed) still has a perfectly good match to show, just without the * existed) still has a perfectly good match to show, just without the
* wider highlight, so those two are included only when both are present * wider highlight, so those two are included only when both are present
* rather than dropping the whole entry over a still-missing "nice to have". * rather than dropping the whole entry over a still-missing "nice to have".
*
* Exported also called by `recipe-tech-step-correction.service.ts` to
* shape the fresh `StepTechStep` sequence it returns right after applying
* a manual correction, so both places convert the exact same way rather
* than risking two slightly different views of the same rows.
*/ */
function toStepTechStepViews( export function toStepTechStepViews(
techSteps: RecipeWithDetails["steps"][number]["techSteps"], techSteps: RecipeWithDetails["steps"][number]["techSteps"],
): StepTechStepView[] { ): StepTechStepView[] {
const views: StepTechStepView[] = []; const views: StepTechStepView[] = [];
for (const stepTechStep of techSteps) { for (const stepTechStep of techSteps) {
const { start, end, contextStart, contextEnd, techStep } = stepTechStep; const { start, end, contextStart, contextEnd, techStep, source } = stepTechStep;
if (start === null || end === null) continue; if (start === null || end === null) continue;
views.push({ views.push({
techStep: { id: techStep.id, key: techStep.key }, techStep: { id: techStep.id, key: techStep.key },
start, start,
end, end,
// `source` is a plain DB `String`, not a Prisma enum (see
// `StepTechStep`'s schema doc comment) — narrowed here rather than
// trusting the column's own type, so a value this app never wrote
// (a manual DB edit, a future migration gone wrong) degrades to the
// safer "auto" reading instead of surfacing an invalid
// `StepTechStepView.source` to the frontend.
source: source === "manual" ? "manual" : "auto",
...(contextStart !== null && contextEnd !== null ? { contextStart, contextEnd } : {}), ...(contextStart !== null && contextEnd !== null ? { contextStart, contextEnd } : {}),
}); });
} }

View file

@ -225,6 +225,12 @@ export async function previewSourceItem(
end: match.end, end: match.end,
contextStart: match.contextStart, contextStart: match.contextStart,
contextEnd: match.contextEnd, contextEnd: match.contextEnd,
// A draft preview has no persisted `StepTechStep` row to
// read a real `source` from at all (it isn't a saved
// recipe yet — see `DraftRecipeStepView`'s own doc
// comment) — always the classifier's own live match,
// never a correction, so always "auto".
source: "auto",
}, },
] ]
: []; : [];

View file

@ -1,12 +1,15 @@
import { pathToFileURL } from "node:url";
import { prisma } from "../db/prisma.js"; import { prisma } from "../db/prisma.js";
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js"; import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
import { renumberStepTechSteps } from "../modules/recipe/recipe-tech-step-correction.service.js";
/** /**
* Recomputes every existing `Step`'s `StepTechStep` sequence against the * Recomputes every existing `Step`'s `"auto"`-sourced `StepTechStep`
* *current* classifier (`tech-step-matcher.ts`/`tech-step-training-data.ts`), * entries against the *current* classifier
* the same way `updateRecipe` does when a user resaves a recipe through the * (`tech-step-matcher.ts`/`tech-step-training-data.ts`), the same way
* UI always `"fr"` (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`; * `updateRecipe` does when a user resaves a recipe through the UI
* there's no persisted per-recipe locale to recover for a step that already * 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 * exists, so this matches real resave behavior exactly rather than
* guessing). * guessing).
* *
@ -16,13 +19,26 @@ import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js"
* `contextStart`/`contextEnd` columns a previous session added) keeps * `contextStart`/`contextEnd` columns a previous session added) keeps
* whatever it was matched with at the time until it's next resaved. * whatever it was matched with at the time until it's next resaved.
* *
* `"manual"`-sourced entries (a viewer's correction, applied immediately
* see `recipe-tech-step-correction.service.ts`'s `applyManualCorrection`)
* are never touched by this: only rows with `source: "auto"` are deleted
* and recreated, and any fresh classifier match overlapping an existing
* `"manual"` entry's span is dropped rather than inserted a manual
* correction is meant to *override* the classifier at that exact spot,
* and recomputing must never silently reintroduce (or duplicate-highlight)
* what a user already corrected. `renumberStepTechSteps`
* (`recipe-tech-step-correction.service.ts`) folds the surviving `"auto"` +
* untouched `"manual"` rows back into one coherent reading-order sequence
* afterward.
*
* Exported (not just called from this file's own CLI guard below) so * Exported (not just called from this file's own CLI guard below) so
* `retrain-tech-steps.ts` can run it as one step of its own larger * `retrain-tech-steps.ts` can run it as one step of its own larger
* maintainer workflow, without shelling out to a second process. * maintainer workflow, without shelling out to a second process.
* *
* Safe to re-run: each step's technique sequence is fully replaced (delete * Safe to re-run: with no manual entries and no corpus change since the
* + recreate) from the classifier's current output, same as a real edit * last run, this is a no-op (the same `"auto"` matches get deleted and
* running it twice in a row with no corpus change in between is a no-op. * recreated identically); with manual entries present, they're preserved
* on every run by construction.
*/ */
export async function backfillTechSteps(): Promise<{ total: number; changed: number }> { export async function backfillTechSteps(): Promise<{ total: number; changed: number }> {
const steps = await prisma.step.findMany({ select: { id: true, description: true } }); const steps = await prisma.step.findMany({ select: { id: true, description: true } });
@ -31,20 +47,47 @@ export async function backfillTechSteps(): Promise<{ total: number; changed: num
let changed = 0; let changed = 0;
for (const step of steps) { for (const step of steps) {
const matches = await techStepClassifier.matchTechStepSpans(step.description, "fr"); const matches = await techStepClassifier.matchTechStepSpans(step.description, "fr");
await prisma.$transaction([
prisma.stepTechStep.deleteMany({ where: { stepId: step.id } }), await prisma.$transaction(async (tx) => {
prisma.stepTechStep.createMany({ const manualRows = await tx.stepTechStep.findMany({
data: matches.map((match, order) => ({ where: { stepId: step.id, source: "manual" },
stepId: step.id, });
techStepId: match.techStepId,
order, const nonOverlappingMatches = matches.filter(
start: match.start, (match) =>
end: match.end, !manualRows.some(
contextStart: match.contextStart, (manual) =>
contextEnd: match.contextEnd, manual.start !== null &&
})), manual.end !== null &&
}), manual.start < match.end &&
]); match.start < manual.end,
),
);
await tx.stepTechStep.deleteMany({ where: { stepId: step.id, source: "auto" } });
if (nonOverlappingMatches.length > 0) {
// Placeholder orders, disjoint from the untouched manual rows'
// existing ones (`renumberStepTechSteps` below folds everything
// into a clean 0..N-1 sequence right after — these just need to
// not collide with `@@id([stepId, order])` for this insert).
const startOrder = manualRows.reduce((max, row) => Math.max(max, row.order), -1) + 1;
await tx.stepTechStep.createMany({
data: nonOverlappingMatches.map((match, index) => ({
stepId: step.id,
techStepId: match.techStepId,
order: startOrder + index,
start: match.start,
end: match.end,
contextStart: match.contextStart,
contextEnd: match.contextEnd,
source: "auto",
})),
});
}
await renumberStepTechSteps(tx, step.id);
});
changed += 1; changed += 1;
} }
@ -57,8 +100,18 @@ export async function backfillTechSteps(): Promise<{ total: number; changed: num
// imports `backfillTechSteps` above — the standard ESM "is this the entry // imports `backfillTechSteps` above — the standard ESM "is this the entry
// module" check, first needed in this codebase by that new script; every // module" check, first needed in this codebase by that new script; every
// prior script here (`seed-runtime.ts`) was always only ever run directly, // prior script here (`seed-runtime.ts`) was always only ever run directly,
// never imported. // never imported. `pathToFileURL` (not a naive `` `file://${process.argv[1]}` ``
const isMainModule = import.meta.url === `file://${process.argv[1]}`; // concatenation) is required for this to actually work on Windows — a
// native Windows path (backslashes, no leading slash before the drive
// letter) doesn't survive being pasted directly after `file://`, so the
// comparison against `import.meta.url` (already a real, correctly-escaped
// `file:///D:/...` URL) always came out false: this guard silently never
// matched, so running this script directly (`tsx
// src/scripts/backfill-tech-steps.ts`) did *nothing* — no error, no
// output, `backfillTechSteps()` simply never called — found only by
// running it for real and noticing zero output where several log lines
// were expected.
const isMainModule = import.meta.url === pathToFileURL(process.argv[1] ?? "").href;
if (isMainModule) { if (isMainModule) {
backfillTechSteps() backfillTechSteps()
.then(() => prisma.$disconnect()) .then(() => prisma.$disconnect())

View file

@ -75,8 +75,13 @@ describe("Recipe tech-step corrections", () => {
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
}); });
it("records a correction adding a missing technique (no previousTechStepId)", async () => { it("records a correction adding a missing technique (no previousTechStepId), and applies it immediately to the step's own techSteps", async () => {
const { agent, profileId } = await signup(); 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,
// 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); const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer"); const simmerId = await techStepId("simmer");
@ -85,25 +90,59 @@ describe("Recipe tech-step corrections", () => {
.send({ start: 6, end: 13, correctedTechStepId: simmerId }); .send({ start: 6, end: 13, correctedTechStepId: simmerId });
expect(res.status).to.equal(201); expect(res.status).to.equal(201);
expect(res.body.previousTechStep).to.equal(null); expect(res.body.correction.previousTechStep).to.equal(null);
expect(res.body.correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" }); expect(res.body.correction.correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
expect(res.body.start).to.equal(6); expect(res.body.correction.start).to.equal(6);
expect(res.body.end).to.equal(13); expect(res.body.correction.end).to.equal(13);
// The step's real technique sequence reflects the correction right
// away — not just the permanent audit record above (see
// `applyManualCorrection`, `recipe-tech-step-correction.service.ts`).
expect(res.body.techSteps).to.deep.equal([
{ techStep: { id: simmerId, key: "simmer" }, start: 6, end: 13, source: "manual" },
]);
}); });
it("records a correction relabeling an existing match (both ids set)", async () => { it("records a correction relabeling an existing match (both ids set), updating the existing techSteps entry in place", async () => {
const { agent, profileId } = await signup(); const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId); const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer"); const simmerId = await techStepId("simmer");
const boilId = await techStepId("boil"); const boilId = await techStepId("boil");
// First correction creates the "manual" entry this test then relabels
// — exercises the UPDATE branch of `applyManualCorrection`, not the
// INSERT one the previous test already covers.
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
const res = await agent const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`) .post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId }); .send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
expect(res.status).to.equal(201); expect(res.status).to.equal(201);
expect(res.body.previousTechStep).to.deep.equal({ id: simmerId, key: "simmer" }); expect(res.body.correction.previousTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
expect(res.body.correctedTechStep).to.deep.equal({ id: boilId, key: "boil" }); expect(res.body.correction.correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
// Still exactly one entry — the relabel updated the existing row
// rather than adding a second one alongside it.
expect(res.body.techSteps).to.deep.equal([
{ techStep: { id: boilId, key: "boil" }, start: 6, end: 13, source: "manual" },
]);
});
it("deletes the matching techSteps entry when correctedTechStepId is null (a removal)", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: null });
expect(res.status).to.equal(201);
expect(res.body.correction.correctedTechStep).to.equal(null);
expect(res.body.techSteps).to.deep.equal([]);
}); });
it("is not restricted to the recipe's author — any viewer who can see it may correct it", async () => { it("is not restricted to the recipe's author — any viewer who can see it may correct it", async () => {

View file

@ -6,18 +6,20 @@ import { splitDescriptionByTechSteps } from "../../src/features/recipes/steps/hi
// `expect`, not for rendering. `.cy.tsx` (not `.cy.ts`) only because that's // `expect`, not for rendering. `.cy.tsx` (not `.cy.ts`) only because that's
// what `cypress.config.ts`'s component `specPattern` looks for. // what `cypress.config.ts`'s component `specPattern` looks for.
/** Builds a `StepTechStepView` — `context` omitted entirely (not just undefined) when absent, matching what the API actually sends for an older, not-yet-recomputed match (see `StepTechStepView`'s own doc comment). */ /** Builds a `StepTechStepView` — `context` omitted entirely (not just undefined) when absent, matching what the API actually sends for an older, not-yet-recomputed match (see `StepTechStepView`'s own doc comment). `source` defaults to `"auto"`, the common case every test not specifically about the manual/auto distinction uses. */
function techStep( function techStep(
key: string, key: string,
id: number, id: number,
start: number, start: number,
end: number, end: number,
context?: { start: number; end: number }, context?: { start: number; end: number },
source: StepTechStepView["source"] = "auto",
): StepTechStepView { ): StepTechStepView {
return { return {
techStep: { id, key }, techStep: { id, key },
start, start,
end, end,
source,
...(context ? { contextStart: context.start, contextEnd: context.end } : {}), ...(context ? { contextStart: context.start, contextEnd: context.end } : {}),
}; };
} }
@ -25,7 +27,7 @@ function techStep(
describe("splitDescriptionByTechSteps", () => { describe("splitDescriptionByTechSteps", () => {
it("returns the whole description as one plain segment when there are no matches", () => { it("returns the whole description as one plain segment when there are no matches", () => {
expect(splitDescriptionByTechSteps("Servir immédiatement", [])).to.deep.equal([ expect(splitDescriptionByTechSteps("Servir immédiatement", [])).to.deep.equal([
{ text: "Servir immédiatement", techStep: null, isKeyword: false }, { text: "Servir immédiatement", techStep: null, isKeyword: false, source: null },
]); ]);
}); });
@ -37,25 +39,25 @@ describe("splitDescriptionByTechSteps", () => {
techStep("simmer", 1, 6, 13), techStep("simmer", 1, 6, 13),
]); ]);
expect(result).to.deep.equal([ expect(result).to.deep.equal([
{ text: "Faire ", techStep: null, isKeyword: false }, { text: "Faire ", techStep: null, isKeyword: false, source: null },
{ text: "mijoter", techStep: { id: 1, key: "simmer" }, isKeyword: true }, { text: "mijoter", techStep: { id: 1, key: "simmer" }, isKeyword: true, source: "auto" },
{ text: " à feu doux", techStep: null, isKeyword: false }, { text: " à feu doux", techStep: null, isKeyword: false, source: null },
]); ]);
}); });
it("handles a match at the very start, with nothing before it", () => { it("handles a match at the very start, with nothing before it", () => {
const result = splitDescriptionByTechSteps("Hacher les oignons", [techStep("chop", 2, 0, 6)]); const result = splitDescriptionByTechSteps("Hacher les oignons", [techStep("chop", 2, 0, 6)]);
expect(result).to.deep.equal([ expect(result).to.deep.equal([
{ text: "Hacher", techStep: { id: 2, key: "chop" }, isKeyword: true }, { text: "Hacher", techStep: { id: 2, key: "chop" }, isKeyword: true, source: "auto" },
{ text: " les oignons", techStep: null, isKeyword: false }, { text: " les oignons", techStep: null, isKeyword: false, source: null },
]); ]);
}); });
it("handles a match at the very end, with nothing after it", () => { it("handles a match at the very end, with nothing after it", () => {
const result = splitDescriptionByTechSteps("Faire cuire", [techStep("cook", 3, 6, 11)]); const result = splitDescriptionByTechSteps("Faire cuire", [techStep("cook", 3, 6, 11)]);
expect(result).to.deep.equal([ expect(result).to.deep.equal([
{ text: "Faire ", techStep: null, isKeyword: false }, { text: "Faire ", techStep: null, isKeyword: false, source: null },
{ text: "cuire", techStep: { id: 3, key: "cook" }, isKeyword: true }, { text: "cuire", techStep: { id: 3, key: "cook" }, isKeyword: true, source: "auto" },
]); ]);
}); });
@ -71,6 +73,7 @@ describe("splitDescriptionByTechSteps", () => {
text: "Préchauffer", text: "Préchauffer",
techStep: { id: 4, key: "preheat" }, techStep: { id: 4, key: "preheat" },
isKeyword: true, isKeyword: true,
source: "auto",
}); });
}); });
@ -88,17 +91,23 @@ describe("splitDescriptionByTechSteps", () => {
it("drops a match whose end is past the end of the description", () => { it("drops a match whose end is past the end of the description", () => {
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 0, 999)]); const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 0, 999)]);
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]); expect(result).to.deep.equal([
{ text: "Cuire", techStep: null, isKeyword: false, source: null },
]);
}); });
it("drops a match with a negative start", () => { it("drops a match with a negative start", () => {
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, -1, 5)]); const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, -1, 5)]);
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]); expect(result).to.deep.equal([
{ text: "Cuire", techStep: null, isKeyword: false, source: null },
]);
}); });
it("drops a match whose start isn't before its end", () => { it("drops a match whose start isn't before its end", () => {
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 3, 3)]); const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 3, 3)]);
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]); expect(result).to.deep.equal([
{ text: "Cuire", techStep: null, isKeyword: false, source: null },
]);
}); });
it("drops a later match that overlaps one already accepted", () => { it("drops a later match that overlaps one already accepted", () => {
@ -110,7 +119,7 @@ describe("splitDescriptionByTechSteps", () => {
techStep("cook", 2, 0, 5), techStep("cook", 2, 0, 5),
]); ]);
expect(result).to.deep.equal([ expect(result).to.deep.equal([
{ text: "Cuire au four", techStep: { id: 3, key: "bake" }, isKeyword: true }, { text: "Cuire au four", techStep: { id: 3, key: "bake" }, isKeyword: true, source: "auto" },
]); ]);
}); });
@ -118,6 +127,19 @@ describe("splitDescriptionByTechSteps", () => {
expect(splitDescriptionByTechSteps("", [])).to.deep.equal([]); expect(splitDescriptionByTechSteps("", [])).to.deep.equal([]);
}); });
it("carries a manual correction's source through its segments, distinct from an auto match", () => {
const text = "Faire mijoter le riz, puis dresser dans les assiettes";
const result = splitDescriptionByTechSteps(text, [
techStep("simmer", 1, 6, 13),
techStep("plate", 2, 28, 35, undefined, "manual"),
]);
const keywordSegments = result.filter((s) => s.isKeyword);
expect(keywordSegments.map((s) => ({ key: s.techStep?.key, source: s.source }))).to.deep.equal([
{ key: "simmer", source: "auto" },
{ key: "plate", source: "manual" },
]);
});
describe("with a context span wider than the keyword", () => { describe("with a context span wider than the keyword", () => {
it("splits into context-before / keyword / context-after around a keyword in the middle of its clause", () => { it("splits into context-before / keyword / context-after around a keyword in the middle of its clause", () => {
// The motivating example: "Dans une poêle chaude, faire chauffer une // The motivating example: "Dans une poêle chaude, faire chauffer une
@ -128,12 +150,23 @@ describe("splitDescriptionByTechSteps", () => {
techStep("preheat", 4, 9, 21, { start: 0, end: 21 }), techStep("preheat", 4, 9, 21, { start: 0, end: 21 }),
]); ]);
expect(result).to.deep.equal([ expect(result).to.deep.equal([
{ text: "Dans une ", techStep: { id: 4, key: "preheat" }, isKeyword: false }, {
{ text: "poêle chaude", techStep: { id: 4, key: "preheat" }, isKeyword: true }, text: "Dans une ",
techStep: { id: 4, key: "preheat" },
isKeyword: false,
source: "auto",
},
{
text: "poêle chaude",
techStep: { id: 4, key: "preheat" },
isKeyword: true,
source: "auto",
},
{ {
text: ", faire chauffer une noix de beurre", text: ", faire chauffer une noix de beurre",
techStep: null, techStep: null,
isKeyword: false, isKeyword: false,
source: null,
}, },
]); ]);
}); });
@ -143,8 +176,13 @@ describe("splitDescriptionByTechSteps", () => {
techStep("preheat", 4, 0, 11, { start: 0, end: 19 }), techStep("preheat", 4, 0, 11, { start: 0, end: 19 }),
]); ]);
expect(result).to.deep.equal([ expect(result).to.deep.equal([
{ text: "préchauffer", techStep: { id: 4, key: "preheat" }, isKeyword: true }, {
{ text: " le four", techStep: { id: 4, key: "preheat" }, isKeyword: false }, text: "préchauffer",
techStep: { id: 4, key: "preheat" },
isKeyword: true,
source: "auto",
},
{ text: " le four", techStep: { id: 4, key: "preheat" }, isKeyword: false, source: "auto" },
]); ]);
}); });
@ -153,9 +191,19 @@ describe("splitDescriptionByTechSteps", () => {
techStep("preheat", 4, 17, 28, { start: 7, end: 28 }), techStep("preheat", 4, 17, 28, { start: 7, end: 28 }),
]); ]);
expect(result).to.deep.equal([ expect(result).to.deep.equal([
{ text: "mettre ", techStep: null, isKeyword: false }, { text: "mettre ", techStep: null, isKeyword: false, source: null },
{ text: "le four à ", techStep: { id: 4, key: "preheat" }, isKeyword: false }, {
{ text: "préchauffer", techStep: { id: 4, key: "preheat" }, isKeyword: true }, text: "le four à ",
techStep: { id: 4, key: "preheat" },
isKeyword: false,
source: "auto",
},
{
text: "préchauffer",
techStep: { id: 4, key: "preheat" },
isKeyword: true,
source: "auto",
},
]); ]);
}); });
@ -171,7 +219,9 @@ describe("splitDescriptionByTechSteps", () => {
// contextEnd (5) is before the keyword's own end (13) — malformed. // contextEnd (5) is before the keyword's own end (13) — malformed.
techStep("bake", 3, 0, 13, { start: 0, end: 5 }), techStep("bake", 3, 0, 13, { start: 0, end: 5 }),
]); ]);
expect(result).to.deep.equal([{ text: "Cuire au four", techStep: null, isKeyword: false }]); expect(result).to.deep.equal([
{ text: "Cuire au four", techStep: null, isKeyword: false, source: null },
]);
}); });
}); });
}); });

View file

@ -27,7 +27,7 @@ Feature: Managing a recipe from the catalog
When I focus the highlighted technique "Cuire" When I focus the highlighted technique "Cuire"
Then the tooltip should show "Cuire" Then the tooltip should show "Cuire"
Scenario: Corrects a detected technique from its highlight Scenario: Corrects a detected technique from its highlight, visible immediately as a manual match
Given the recipe catalog contains "Omelette" Given the recipe catalog contains "Omelette"
And recipe 2's detail is available And recipe 2's detail is available
And the tech steps reference list has options And the tech steps reference list has options
@ -37,7 +37,13 @@ Feature: Managing a recipe from the catalog
Then I should see the technique correction options Then I should see the technique correction options
When I choose "Mijoter" as the correct technique When I choose "Mijoter" as the correct technique
Then the correction request should have been made Then the correction request should have been made
And I should see "Merci, votre correction a été enregistrée." # The highlighted *word* stays "Cuire" (a relabel changes which
# technique a span means, not the literal text at that span, still
# "Cuire" in the source description) — now styled as a manual
# correction, with its tooltip naming the newly-assigned technique.
And the highlighted technique "Cuire" should be marked as a manual correction
When I focus the highlighted technique "Cuire"
Then the tooltip should show "Mijoter (correction manuelle)"
Scenario: Deletes a recipe after a two-step confirmation, then clears the selection Scenario: Deletes a recipe after a two-step confirmation, then clears the selection
Given the recipe catalog contains "Omelette" Given the recipe catalog contains "Omelette"

View file

@ -43,7 +43,7 @@ const omeletteDetail = {
// "Cuire" -> the `cook` technique, matching real reference-seed-data.ts // "Cuire" -> the `cook` technique, matching real reference-seed-data.ts
// (`\bcui(re|sez|sant|sson)\b`) — "poêle" itself matches nothing // (`\bcui(re|sez|sant|sson)\b`) — "poêle" itself matches nothing
// (that's `panFry`'s "sauter", a different word). // (that's `panFry`'s "sauter", a different word).
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }], techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5, source: "auto" }],
}, },
], ],
}; };
@ -70,19 +70,25 @@ Given("deleting recipe 2 will succeed", () => {
// Step 2 is `omeletteDetail`'s "Cuire à la poêle." step, whose only // Step 2 is `omeletteDetail`'s "Cuire à la poêle." step, whose only
// existing match is `cook` (id 1) — see that fixture above. The response // existing match is `cook` (id 1) — see that fixture above. The response
// mirrors `StepTechStepCorrectionView` (packages/shared), reassigning the // mirrors `SubmitTechStepCorrectionResult` (packages/shared): the audit
// match to `simmer` (id 3, "Mijoter" — see `the tech steps reference list // record (reassigning the match to `simmer`, id 3, "Mijoter" — see `the
// has options`, reference-data.steps.ts). // tech steps reference list has options`, reference-data.steps.ts) plus
// the step's fresh `techSteps`, now showing that same reassignment as a
// `"manual"`-sourced entry — the API applies a correction immediately, it
// doesn't just record it (see `StepTechStepView.source`'s own doc comment).
Given('correcting step 2\'s "Cuire" match will succeed', () => { Given('correcting step 2\'s "Cuire" match will succeed', () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", { cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201, statusCode: 201,
body: { body: {
id: 1, correction: {
start: 0, id: 1,
end: 5, start: 0,
previousTechStep: { id: 1, key: "cook" }, end: 5,
correctedTechStep: { id: 3, key: "simmer" }, previousTechStep: { id: 1, key: "cook" },
createdAt: new Date().toISOString(), correctedTechStep: { id: 3, key: "simmer" },
createdAt: new Date().toISOString(),
},
techSteps: [{ techStep: { id: 3, key: "simmer" }, start: 0, end: 5, source: "manual" }],
}, },
}).as("correction"); }).as("correction");
}); });
@ -99,6 +105,13 @@ When("I choose {string} as the correct technique", (label: string) => {
cy.contains(".tech-step-correction-popover__list button", label).click(); cy.contains(".tech-step-correction-popover__list button", label).click();
}); });
Then(
"the highlighted technique {string} should be marked as a manual correction",
(text: string) => {
cy.contains(".step-tech-step", text).should("have.class", "step-tech-step--manual");
},
);
Then("the correction request should have been made", () => { Then("the correction request should have been made", () => {
// Asserts the actual span, not just that *a* request fired — a real bug // Asserts the actual span, not just that *a* request fired — a real bug
// (StepDescription.tsx's click handler reading a shared, still-mutating // (StepDescription.tsx's click handler reading a shared, still-mutating

View file

@ -21,6 +21,7 @@ import {
type SourceView, type SourceView,
type StepTechStepCorrectionView, type StepTechStepCorrectionView,
type SubmitTechStepCorrectionInput, type SubmitTechStepCorrectionInput,
type SubmitTechStepCorrectionResult,
type TechStepView, type TechStepView,
type ThemePreference, type ThemePreference,
type UnitView, type UnitView,
@ -275,12 +276,12 @@ export class ApiClient {
return this._request(`/recipes/${id}/favorite`, { method: "DELETE" }); return this._request(`/recipes/${id}/favorite`, { method: "DELETE" });
} }
/** Submits a correction to one of `stepId`'s detected techniques — see `SubmitTechStepCorrectionInput`'s doc comment (`packages/shared`) for what `previousTechStepId`/`correctedTechStepId` each mean. Open to any viewer who can see the recipe, not just its author. */ /** Submits a correction to one of `stepId`'s detected techniques — see `SubmitTechStepCorrectionInput`'s doc comment (`packages/shared`) for what `previousTechStepId`/`correctedTechStepId` each mean. Open to any viewer who can see the recipe, not just its author. The response's `techSteps` is the step's fresh, immediately up-to-date technique sequence — see `SubmitTechStepCorrectionResult`'s doc comment. */
public submitTechStepCorrection( public submitTechStepCorrection(
recipeId: number, recipeId: number,
stepId: number, stepId: number,
input: SubmitTechStepCorrectionInput, input: SubmitTechStepCorrectionInput,
): Promise<StepTechStepCorrectionView> { ): Promise<SubmitTechStepCorrectionResult> {
return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`, { return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`, {
method: "POST", method: "POST",
body: JSON.stringify(input), body: JSON.stringify(input),

View file

@ -205,6 +205,16 @@ export function RecipeDetailPanel({
<section className="recipe-detail-panel__section"> <section className="recipe-detail-panel__section">
<h3>{t("recipes.stepsTitle")}</h3> <h3>{t("recipes.stepsTitle")}</h3>
{/* Discoverability hint for the highlight/correction feature below
nothing about the steps list itself otherwise signals that a
highlighted technique or a plain-text selection is interactive.
Tied to `showActions`, same reasoning as `StepDescription`'s own
`editable` prop right below. */}
{showActions && (
<p className="recipe-detail-panel__tech-step-hint">
{t("recipes.techStepCorrection.discoverabilityHint")}
</p>
)}
<ol className="recipe-detail-panel__steps"> <ol className="recipe-detail-panel__steps">
{recipe.steps.map((step) => ( {recipe.steps.map((step) => (
<li key={step.id}> <li key={step.id}>

View file

@ -627,12 +627,35 @@
} }
} }
// A `"manual"`-sourced match (a viewer's correction, applied immediately —
// see `StepTechStepView.source`) same shape as `.step-tech-step`, but in
// `--color-tag` (Turmeric) instead of `--color-primary` (Basil), so the two
// origins are distinguishable at a glance, not just via the tooltip text.
.step-tech-step--manual {
background: color-mix(in srgb, var(--color-tag) 18%, transparent);
text-decoration-color: var(--color-tag);
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--color-tag) 28%, transparent);
}
}
// `.step-tech-step-context` (the wider clause a `.step-tech-step` keyword // `.step-tech-step-context` (the wider clause a `.step-tech-step` keyword
// was found in) used to be highlighted here too, more subtly turned back // was found in) used to be highlighted here too, more subtly turned back
// off (see `StepDescription.tsx`'s doc comment): the backend still // off (see `StepDescription.tsx`'s doc comment): the backend still
// computes and persists `contextStart`/`contextEnd`, this file just no // computes and persists `contextStart`/`contextEnd`, this file just no
// longer gives that class any styling to render with. // longer gives that class any styling to render with.
// Discoverability hint above the steps list (RecipeDetailPanel.tsx)
// muted so it reads as a small aside, not competing with the steps
// themselves for attention.
.recipe-detail-panel__tech-step-hint {
margin: 0 0 var(--space-sm);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// --- Tech-step correction (StepDescription.tsx editable mode) --------------- // --- Tech-step correction (StepDescription.tsx editable mode) ---------------
// Deliberately *not* `position: absolute` (unlike `.calendar-popover`) see // Deliberately *not* `position: absolute` (unlike `.calendar-popover`) see
@ -699,12 +722,6 @@
} }
} }
.step-tech-step-correction-confirmation {
margin-top: var(--space-xs);
font-size: var(--font-size-sm);
color: var(--color-success, var(--color-primary));
}
// --- Favorite star toggle (detail panel header) ----------------------------- // --- Favorite star toggle (detail panel header) -----------------------------
.favorite-star-button { .favorite-star-button {
position: absolute; position: absolute;

View file

@ -1,14 +1,11 @@
import type { StepTechStepCorrectionView, StepTechStepView } from "@batch-cooking/shared"; import type { StepTechStepView, SubmitTechStepCorrectionResult } from "@batch-cooking/shared";
import { Fragment, useRef, useState } from "react"; import { Fragment, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Tooltip } from "../../../components/ui/Tooltip"; import { Tooltip } from "../../../components/ui/Tooltip";
import { splitDescriptionByTechSteps } from "./highlight-tech-steps"; import { splitDescriptionByTechSteps } from "./highlight-tech-steps";
import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover"; import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
import { type TextSelectionRange, useTextSelection } from "./use-text-selection"; import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
/** How long the post-submit confirmation message stays visible — long enough to read, short enough not to linger once the user has moved on. */
const CONFIRMATION_DISPLAY_MS = 4000;
/** /**
* A recipe step's description, with every detected technique's exact * A recipe step's description, with every detected technique's exact
* matched words highlighted and given a {@link Tooltip} naming the * matched words highlighted and given a {@link Tooltip} naming the
@ -28,7 +25,11 @@ const CONFIRMATION_DISPLAY_MS = 4000;
* *
* `techStep.key` resolves its tooltip label through `catalog.techSteps.<key>` * `techStep.key` resolves its tooltip label through `catalog.techSteps.<key>`
* i18n, the same pattern every other reference catalog (diets, units, ) * i18n, the same pattern every other reference catalog (diets, units, )
* uses for its display text. * uses for its display text. A keyword's `source` (`"auto"` the
* classifier vs `"manual"` a viewer's correction, applied immediately)
* gets its own modifier class (`.step-tech-step--manual`), a different
* color, so the two are visually distinguishable at a glance rather than
* only via the tooltip text.
* *
* `editable` (off by default) additionally lets the viewer select text or * `editable` (off by default) additionally lets the viewer select text or
* click an existing highlight to open a {@link TechStepCorrectionPopover} * click an existing highlight to open a {@link TechStepCorrectionPopover}
@ -37,6 +38,15 @@ const CONFIRMATION_DISPLAY_MS = 4000;
* renders exactly as before (no extra wrapping elements, no `data-offset`, * renders exactly as before (no extra wrapping elements, no `data-offset`,
* no click handlers) this mode is purely additive, not a rewrite of the * no click handlers) this mode is purely additive, not a rewrite of the
* read-only rendering. * read-only rendering.
*
* Maintains its own local copy of `techSteps` (seeded from the prop, then
* replaced with whatever `POST .../corrections` returns on a successful
* submit see `SubmitTechStepCorrectionResult`'s doc comment,
* `packages/shared`) so a correction's effect (a new/relabeled/removed
* highlight) appears immediately, without needing the parent to re-fetch
* the whole recipe. Resynced whenever the `techSteps` prop itself changes
* (e.g. the parent reloaded the recipe for an unrelated reason) so this
* never keeps showing stale local state past that.
*/ */
export function StepDescription({ export function StepDescription({
description, description,
@ -53,7 +63,10 @@ export function StepDescription({
stepId?: number; stepId?: number;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const segments = splitDescriptionByTechSteps(description, techSteps); const [liveTechSteps, setLiveTechSteps] = useState(techSteps);
useEffect(() => setLiveTechSteps(techSteps), [techSteps]);
const segments = splitDescriptionByTechSteps(description, liveTechSteps);
const containerRef = useRef<HTMLParagraphElement>(null); const containerRef = useRef<HTMLParagraphElement>(null);
const { getSelectionRange } = useTextSelection(containerRef); const { getSelectionRange } = useTextSelection(containerRef);
@ -62,7 +75,6 @@ export function StepDescription({
selectedText: string; selectedText: string;
previousTechStepId: number | null; previousTechStepId: number | null;
} | null>(null); } | null>(null);
const [showConfirmation, setShowConfirmation] = useState(false);
function handleMouseUp() { function handleMouseUp() {
if (!editable) return; if (!editable) return;
@ -75,9 +87,8 @@ export function StepDescription({
}); });
} }
function handleSubmitted(_correction: StepTechStepCorrectionView) { function handleSubmitted(result: SubmitTechStepCorrectionResult) {
setShowConfirmation(true); setLiveTechSteps(result.techSteps);
window.setTimeout(() => setShowConfirmation(false), CONFIRMATION_DISPLAY_MS);
} }
// Tracks each segment's own absolute start offset into `description` as // Tracks each segment's own absolute start offset into `description` as
@ -103,8 +114,9 @@ export function StepDescription({
// A segment's own text/techStep don't uniquely identify it (the // A segment's own text/techStep don't uniquely identify it (the
// same word can appear twice in one description) — index is the // same word can appear twice in one description) — index is the
// only thing that does, but this list is fully regenerated from // only thing that does, but this list is fully regenerated from
// `description`/`techSteps` on every render (never reordered or // `description`/`liveTechSteps` on every render (never reordered
// spliced in place), so using it as part of the key is safe here. // or spliced in place), so using it as part of the key is safe
// here.
const key = `${index}-${segment.text}`; const key = `${index}-${segment.text}`;
if (!segment.techStep || !segment.isKeyword) { if (!segment.techStep || !segment.isKeyword) {
@ -123,8 +135,14 @@ export function StepDescription({
} }
const techStep = segment.techStep; const techStep = segment.techStep;
const isManual = segment.source === "manual";
const tooltipLabel = isManual
? t("recipes.techStepCorrection.manualTooltip", {
technique: t(`catalog.techSteps.${techStep.key}`),
})
: t(`catalog.techSteps.${techStep.key}`);
return ( return (
<Tooltip key={key} content={t(`catalog.techSteps.${techStep.key}`)}> <Tooltip key={key} content={tooltipLabel}>
{/* A real <button>, not a <mark>, so it's natively focusable {/* A real <button>, not a <mark>, so it's natively focusable
(keyboard/screen-reader users can reach the tooltip) without (keyboard/screen-reader users can reach the tooltip) without
fighting the "non-interactive element" a11y lint a bare fighting the "non-interactive element" a11y lint a bare
@ -132,7 +150,7 @@ export function StepDescription({
highlighted text, not as a button (see .step-tech-step). */} highlighted text, not as a button (see .step-tech-step). */}
<button <button
type="button" type="button"
className="step-tech-step" className={isManual ? "step-tech-step step-tech-step--manual" : "step-tech-step"}
data-offset={editable ? start : undefined} data-offset={editable ? start : undefined}
onClick={ onClick={
editable editable
@ -151,11 +169,6 @@ export function StepDescription({
); );
})} })}
</p> </p>
{showConfirmation && (
<p className="step-tech-step-correction-confirmation">
{t("recipes.techStepCorrection.confirmation")}
</p>
)}
{editable && activeCorrection && recipeId !== undefined && stepId !== undefined && ( {editable && activeCorrection && recipeId !== undefined && stepId !== undefined && (
<TechStepCorrectionPopover <TechStepCorrectionPopover
recipeId={recipeId} recipeId={recipeId}

View file

@ -1,6 +1,6 @@
import { import {
ErrorCode, ErrorCode,
type StepTechStepCorrectionView, type SubmitTechStepCorrectionResult,
type TechStepView, type TechStepView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@ -23,11 +23,12 @@ import type { TextSelectionRange } from "./use-text-selection";
* position across scroll/resize, at the cost of a little visual distance * position across scroll/resize, at the cost of a little visual distance
* from the selected text itself. * from the selected text itself.
* *
* Submitting never changes what's currently highlighted a correction is * Submitting takes effect immediately the API applies it to the step's
* only ever consumed later, offline, by `services/tech-step-llm-worker` * real `StepTechStep` sequence as it records the correction (a `"manual"`-
* and a maintainer's review (see `StepTechStepCorrection`'s schema doc * tagged entry, see `StepTechStepCorrection`'s schema doc comment) and
* comment) so this only ever confirms the submission, it doesn't try to * returns the fresh sequence, which `onSubmitted` hands back to
* (and can't correctly) predict what the classifier will conclude next. * `StepDescription` to render right away, styled differently from an
* `"auto"` match.
*/ */
export function TechStepCorrectionPopover({ export function TechStepCorrectionPopover({
recipeId, recipeId,
@ -46,7 +47,7 @@ export function TechStepCorrectionPopover({
/** Set when correcting an already-detected match (opened from clicking its highlight) rather than a fresh selection — passed through as-is on submit, and offers a "remove" option `null` doesn't. */ /** Set when correcting an already-detected match (opened from clicking its highlight) rather than a fresh selection — passed through as-is on submit, and offers a "remove" option `null` doesn't. */
previousTechStepId: number | null; previousTechStepId: number | null;
onClose: () => void; onClose: () => void;
onSubmitted: (correction: StepTechStepCorrectionView) => void; onSubmitted: (result: SubmitTechStepCorrectionResult) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
@ -83,13 +84,13 @@ export function TechStepCorrectionPopover({
setIsSubmitting(true); setIsSubmitting(true);
setError(null); setError(null);
try { try {
const correction = await apiClient.submitTechStepCorrection(recipeId, stepId, { const result = await apiClient.submitTechStepCorrection(recipeId, stepId, {
start: range.start, start: range.start,
end: range.end, end: range.end,
previousTechStepId, previousTechStepId,
correctedTechStepId, correctedTechStepId,
}); });
onSubmitted(correction); onSubmitted(result);
onClose(); onClose();
} catch (err) { } catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;

View file

@ -18,6 +18,8 @@ export interface DescriptionSegment {
techStep: StepTechStepView["techStep"] | null; techStep: StepTechStepView["techStep"] | null;
/** Always `false` when `techStep` is `null`. */ /** Always `false` when `techStep` is `null`. */
isKeyword: boolean; isKeyword: boolean;
/** Mirrors the source `StepTechStepView.source` this segment came from — `null` when `techStep` is `null` (nothing to attribute a source to). See `StepDescription.tsx` for how `"auto"` vs `"manual"` render differently. */
source: StepTechStepView["source"] | null;
} }
/** /**
@ -49,7 +51,7 @@ export function splitDescriptionByTechSteps(
const segments: DescriptionSegment[] = []; const segments: DescriptionSegment[] = [];
let cursor = 0; let cursor = 0;
for (const { techStep, start, end, contextStart, contextEnd } of sorted) { for (const { techStep, start, end, contextStart, contextEnd, source } of sorted) {
const wideStart = contextStart ?? start; const wideStart = contextStart ?? start;
const wideEnd = contextEnd ?? end; const wideEnd = contextEnd ?? end;
if ( if (
@ -67,6 +69,7 @@ export function splitDescriptionByTechSteps(
text: description.slice(cursor, wideStart), text: description.slice(cursor, wideStart),
techStep: null, techStep: null,
isKeyword: false, isKeyword: false,
source: null,
}); });
} }
if (start > wideStart) { if (start > wideStart) {
@ -74,16 +77,27 @@ export function splitDescriptionByTechSteps(
text: description.slice(wideStart, start), text: description.slice(wideStart, start),
techStep, techStep,
isKeyword: false, isKeyword: false,
source,
}); });
} }
segments.push({ text: description.slice(start, end), techStep, isKeyword: true }); segments.push({ text: description.slice(start, end), techStep, isKeyword: true, source });
if (wideEnd > end) { if (wideEnd > end) {
segments.push({ text: description.slice(end, wideEnd), techStep, isKeyword: false }); segments.push({
text: description.slice(end, wideEnd),
techStep,
isKeyword: false,
source,
});
} }
cursor = wideEnd; cursor = wideEnd;
} }
if (cursor < description.length) { if (cursor < description.length) {
segments.push({ text: description.slice(cursor), techStep: null, isKeyword: false }); segments.push({
text: description.slice(cursor),
techStep: null,
isKeyword: false,
source: null,
});
} }
return segments; return segments;
} }

View file

@ -167,7 +167,8 @@
"selectionLabel": "« {{text}} »", "selectionLabel": "« {{text}} »",
"removeMatch": "Aucune technique ici", "removeMatch": "Aucune technique ici",
"cancel": "Annuler", "cancel": "Annuler",
"confirmation": "Merci, votre correction a été enregistrée." "manualTooltip": "{{technique}} (correction manuelle)",
"discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger."
}, },
"tabs": { "tabs": {
"favoris": "Favoris", "favoris": "Favoris",

View file

@ -43,6 +43,12 @@ export interface RecipeIngredientView {
* and not yet recomputed (see `StepTechStep`'s schema doc comment) a * and not yet recomputed (see `StepTechStep`'s schema doc comment) a
* caller with no context just shows the keyword highlight alone, same as * caller with no context just shows the keyword highlight alone, same as
* before these existed. * before these existed.
*
* `source` mirrors `StepTechStep.source` (schema.prisma) `"auto"` is the
* classifier's own detection, `"manual"` is a viewer's correction applied
* immediately (`recipe-tech-step-correction.service.ts`'s
* `applyManualCorrection`). `StepDescription.tsx` renders the two with a
* different highlight color so a viewer can tell which is which.
*/ */
export interface StepTechStepView { export interface StepTechStepView {
techStep: TechStepView; techStep: TechStepView;
@ -50,6 +56,7 @@ export interface StepTechStepView {
end: number; end: number;
contextStart?: number; contextStart?: number;
contextEnd?: number; contextEnd?: number;
source: "auto" | "manual";
} }
/** /**
@ -121,3 +128,20 @@ export interface StepTechStepCorrectionView {
correctedTechStep: TechStepView | null; correctedTechStep: TechStepView | null;
createdAt: string; createdAt: string;
} }
/**
* Response of `POST /recipes/:id/steps/:stepId/corrections` the audit
* record just created, plus the step's fresh, immediately up-to-date
* `techSteps` sequence (`StepView.techSteps`'s own shape) after applying
* it. `apps/web`'s `StepDescription.tsx` replaces its local copy of the
* step's `techSteps` with this on a successful submit, so the new/relabeled
* `"manual"`-sourced highlight appears right away the API is the single
* source of truth for exactly which entry changed and how (a relabel
* updates one row in place, an add inserts one, a remove deletes one; see
* `recipe-tech-step-correction.service.ts`'s `applyManualCorrection`), so
* the frontend never tries to replicate that logic client-side.
*/
export interface SubmitTechStepCorrectionResult {
correction: StepTechStepCorrectionView;
techSteps: StepTechStepView[];
}