diff --git a/.env.example b/.env.example
index d17785f..cf00a63 100644
--- a/.env.example
+++ b/.env.example
@@ -21,3 +21,15 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# over plain HTTP a Secure cookie is silently never sent back by the
# browser, so login "succeeds" but every subsequent request 401s.
# COOKIE_SECURE=false
+
+# Only needed to run the optional `tech-step-llm-worker` service — shared
+# between it and "app" (docker-compose.yml). Generate your own the same
+# way as JWT_SECRET above; leave both this and the service commented
+# out/unset to run without it.
+# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
+
+# Optional — cron expression (node-cron syntax) the worker wakes up on to
+# run its audit/feedback-loop jobs. Default: weekly, Sunday 03:00 — a
+# provisional floor, not a calibrated value (see
+# services/tech-step-llm-worker/README.md).
+# TECH_STEP_WORKER_CRON=0 3 * * 0
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f17379c..919405d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,6 +15,10 @@ env:
DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public"
# Test-only secret, never used outside CI — real deployments must set their own.
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
+ # Same reasoning as JWT_SECRET above — lets tech-step-worker.routes.test.ts
+ # exercise the success path (matching secret), not just the "unset"
+ # rejection every environment that doesn't set this gets by default.
+ INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+"
jobs:
# Four independent jobs, no needs: between them — each starts in parallel
diff --git a/.gitignore b/.gitignore
index 93666d2..3507f9b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -157,3 +157,8 @@ tmp-mockups/
# IA
.claude/
+
+# Cypress run artifacts — regenerated locally/in CI, never meant to be committed
+apps/web/cypress/screenshots/
+apps/web/cypress/videos/
+apps/web/cypress/downloads/
diff --git a/apps/api/.env.example b/apps/api/.env.example
index 7dbf7af..9cd6e69 100644
--- a/apps/api/.env.example
+++ b/apps/api/.env.example
@@ -1,14 +1,20 @@
-NODE_ENV=development
-PORT=3000
-# Match whatever you set in the root .env (POSTGRES_USER/PASSWORD/DB) —
-# do not commit the real value.
-DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking?schema=public"
-
-# Required, no default on purpose — generate your own, e.g.:
-# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
-JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
-
-# Optional — defaults shown (see src/config/env.ts)
-# JWT_EXPIRES_IN=7d
-# AUTH_COOKIE_NAME=session
-# CORS_ORIGIN=http://localhost:5173
+NODE_ENV=development
+PORT=3000
+# Match whatever you set in the root .env (POSTGRES_USER/PASSWORD/DB) —
+# do not commit the real value.
+DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking?schema=public"
+
+# Required, no default on purpose — generate your own, e.g.:
+# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
+JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
+
+# Optional — defaults shown (see src/config/env.ts)
+# JWT_EXPIRES_IN=7d
+# AUTH_COOKIE_NAME=session
+# CORS_ORIGIN=http://localhost:5173
+
+# Only needed if you're running services/tech-step-llm-worker locally —
+# every /internal/tech-steps/* request is rejected outright while unset.
+# Generate your own the same way as JWT_SECRET above; must match the
+# worker's own INTERNAL_WORKER_SECRET.
+# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
diff --git a/apps/api/.env.test.example b/apps/api/.env.test.example
index 2d467ad..975619d 100644
--- a/apps/api/.env.test.example
+++ b/apps/api/.env.test.example
@@ -13,3 +13,8 @@ DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking_test?sc
# Required, no default on purpose — generate your own, e.g.:
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
+
+# Optional — only needed to exercise tech-step-worker.routes.test.ts's
+# success path (a request with a matching secret); every other test runs
+# fine without it. Any value at least 32 chars works locally.
+# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
diff --git a/apps/api/prisma/migrations/20260822090000_tech_step_correction_and_suggestion/migration.sql b/apps/api/prisma/migrations/20260822090000_tech_step_correction_and_suggestion/migration.sql
new file mode 100644
index 0000000..3210717
--- /dev/null
+++ b/apps/api/prisma/migrations/20260822090000_tech_step_correction_and_suggestion/migration.sql
@@ -0,0 +1,47 @@
+-- CreateTable
+CREATE TABLE "step_tech_step_correction" (
+ "id" SERIAL NOT NULL,
+ "step_id" INTEGER NOT NULL,
+ "corrector_id" INTEGER NOT NULL,
+ "start" INTEGER NOT NULL,
+ "end" INTEGER NOT NULL,
+ "previous_tech_step_id" INTEGER,
+ "corrected_tech_step_id" INTEGER,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "consumed_at" TIMESTAMP(3),
+
+ CONSTRAINT "step_tech_step_correction_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "tech_step_training_suggestion" (
+ "id" SERIAL NOT NULL,
+ "tech_step_id" INTEGER NOT NULL,
+ "locale" TEXT NOT NULL,
+ "suggested_synonyms" TEXT[],
+ "suggested_utterances" TEXT[],
+ "source_type" TEXT NOT NULL,
+ "source_correction_id" INTEGER,
+ "status" TEXT NOT NULL DEFAULT 'pending',
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "tech_step_training_suggestion_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_step_id_fkey" FOREIGN KEY ("step_id") REFERENCES "step"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_corrector_id_fkey" FOREIGN KEY ("corrector_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_previous_tech_step_id_fkey" FOREIGN KEY ("previous_tech_step_id") REFERENCES "tech_step"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_corrected_tech_step_id_fkey" FOREIGN KEY ("corrected_tech_step_id") REFERENCES "tech_step"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "tech_step_training_suggestion" ADD CONSTRAINT "tech_step_training_suggestion_tech_step_id_fkey" FOREIGN KEY ("tech_step_id") REFERENCES "tech_step"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "tech_step_training_suggestion" ADD CONSTRAINT "tech_step_training_suggestion_source_correction_id_fkey" FOREIGN KEY ("source_correction_id") REFERENCES "step_tech_step_correction"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/apps/api/prisma/migrations/20260822092528_step_tech_step_source/migration.sql b/apps/api/prisma/migrations/20260822092528_step_tech_step_source/migration.sql
new file mode 100644
index 0000000..47089e0
--- /dev/null
+++ b/apps/api/prisma/migrations/20260822092528_step_tech_step_source/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "step_tech_step" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'auto';
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index f1cf994..4aa57db 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -121,6 +121,10 @@ model UserProfile {
/// list regardless of that real-world cardinality.
administeredHouses House[] @relation("HouseAdmin")
preferences UserPreference?
+ /// Tech-step corrections this profile has submitted (any profile that can
+ /// view a recipe may correct its tech-step matches, not just its author —
+ /// see `StepTechStepCorrection.correctorId`).
+ techStepCorrections StepTechStepCorrection[]
@@map("user_profiles")
}
@@ -634,7 +638,16 @@ model TechStep {
id Int @id @default(autoincrement())
key String @unique
- steps StepTechStep[]
+ steps StepTechStep[]
+ /// Corrections where this technique was the *previous* (possibly wrong)
+ /// match — see `StepTechStepCorrection.previousTechStepId`.
+ correctionsAsPrevious StepTechStepCorrection[] @relation("PreviousTechStep")
+ /// Corrections where this technique was the *corrected* (user-asserted)
+ /// match — see `StepTechStepCorrection.correctedTechStepId`.
+ correctionsAsCorrected StepTechStepCorrection[] @relation("CorrectedTechStep")
+ /// Training-corpus suggestions targeting this technique — see
+ /// `TechStepTrainingSuggestion`.
+ trainingSuggestions TechStepTrainingSuggestion[]
@@map("tech_step")
}
@@ -650,8 +663,11 @@ model Step {
picture String?
order Int
- recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
- techSteps StepTechStep[]
+ recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
+ techSteps StepTechStep[]
+ /// User-submitted corrections to this step's detected techniques — see
+ /// `StepTechStepCorrection`.
+ corrections StepTechStepCorrection[]
@@map("step")
}
@@ -682,14 +698,27 @@ model Step {
/// techniques from scratch (`recipe.service.ts`'s `updateRecipe` deletes
/// and recreates every `Step`/`StepTechStep`, never a partial patch) —
/// 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 {
- stepId Int @map("step_id")
- techStepId Int @map("tech_step_id")
+ stepId Int @map("step_id")
+ techStepId Int @map("tech_step_id")
order Int
start Int?
end Int?
- contextStart Int? @map("context_start")
- contextEnd Int? @map("context_end")
+ contextStart Int? @map("context_start")
+ contextEnd Int? @map("context_end")
+ source String @default("auto")
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
@@ -697,3 +726,87 @@ model StepTechStep {
@@id([stepId, order])
@@map("step_tech_step")
}
+
+/// One user-submitted correction to a `Step`'s detected techniques —
+/// captures ADD (a missing technique the classifier didn't find),
+/// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape:
+/// `previousTechStepId` is the (possibly absent) match being corrected,
+/// `correctedTechStepId` is what the user asserts instead (absent means
+/// "no technique belongs here"). Both `null` at once is invalid (nothing
+/// would have changed) — enforced service-side, not by the schema, same
+/// posture as other cross-field invariants in this codebase (e.g.
+/// `RecipeIngredientView`'s no-duplicate-ingredient check).
+///
+/// `start`/`end` are the user's selected `[start, end)` span within
+/// `Step.description` (`String.prototype.slice` convention, same as
+/// `StepTechStep`) — what they highlighted before assigning a technique to
+/// it, not necessarily identical to any existing `StepTechStep` span.
+///
+/// Never edited/deleted once created (an audit trail of what was actually
+/// submitted) — only `consumedAt` changes, stamped once
+/// `services/tech-step-llm-worker` has turned this correction into a
+/// `TechStepTrainingSuggestion` for a maintainer to review, so the same
+/// correction isn't proposed twice on the next scheduled run.
+model StepTechStepCorrection {
+ id Int @id @default(autoincrement())
+ stepId Int @map("step_id")
+ /// Any profile that could *view* the recipe when they submitted this, not
+ /// necessarily its author — see `assertRecipeVisible`,
+ /// `recipe.service.ts`.
+ correctorId Int @map("corrector_id")
+ start Int
+ end Int
+ previousTechStepId Int? @map("previous_tech_step_id")
+ correctedTechStepId Int? @map("corrected_tech_step_id")
+ createdAt DateTime @default(now()) @map("created_at")
+ consumedAt DateTime? @map("consumed_at")
+
+ step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
+ corrector UserProfile @relation(fields: [correctorId], references: [id], onDelete: Cascade)
+ previousTechStep TechStep? @relation("PreviousTechStep", fields: [previousTechStepId], references: [id], onDelete: SetNull)
+ correctedTechStep TechStep? @relation("CorrectedTechStep", fields: [correctedTechStepId], references: [id], onDelete: SetNull)
+ trainingSuggestions TechStepTrainingSuggestion[]
+
+ @@map("step_tech_step_correction")
+}
+
+/// A candidate addition to `TECH_STEP_TRAINING_DATA`
+/// (`tech-step-training-data.ts`), proposed by `services/tech-step-llm-worker`
+/// from one of two sources (`sourceType`):
+///
+/// - `"correction"` — a user's `StepTechStepCorrection`, turned into
+/// suggested synonyms/utterances by the worker's LLM
+/// (`transform-corrections` job).
+/// - `"llm_audit"` — a low-confidence NLP clause on an *existing* recipe the
+/// worker periodically samples and re-judges with its LLM
+/// (`audit-low-confidence` job); no `sourceCorrectionId` in this case.
+///
+/// Deliberately never auto-applied to `tech-step-training-data.ts` — a
+/// maintainer reviews `status: "pending"` rows (see
+/// `list-pending-training-suggestions.ts`) and edits that file by hand,
+/// same "generated suggestion, human-reviewed source of truth" split as a
+/// linter's autofix vs. a human-authored diff. `retrain-tech-steps.ts` then
+/// flips `status` to `"applied"`/`"rejected"` once a maintainer has acted on
+/// a batch, so the same suggestion isn't reviewed twice.
+///
+/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
+/// (`String[]`), not a join table — unlike this schema's other list-shaped
+/// data (`RecipeDiet`, `UserProfileAllergy`...), these strings are free text
+/// proposed once for a human to read, not ids referencing another catalog
+/// table, so there's nothing for a join table to normalize against.
+model TechStepTrainingSuggestion {
+ id Int @id @default(autoincrement())
+ techStepId Int @map("tech_step_id")
+ locale String
+ suggestedSynonyms String[] @map("suggested_synonyms")
+ suggestedUtterances String[] @map("suggested_utterances")
+ sourceType String @map("source_type")
+ sourceCorrectionId Int? @map("source_correction_id")
+ status String @default("pending")
+ createdAt DateTime @default(now()) @map("created_at")
+
+ techStep TechStep @relation(fields: [techStepId], references: [id])
+ sourceCorrection StepTechStepCorrection? @relation(fields: [sourceCorrectionId], references: [id], onDelete: SetNull)
+
+ @@map("tech_step_training_suggestion")
+}
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 4a5c86d..635cb55 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -7,6 +7,7 @@ import { errorLogger } from "./middlewares/error-logger.js";
import { requestLogger } from "./middlewares/request-logger.js";
import { authRouter } from "./modules/auth/auth.routes.js";
import { houseRouter } from "./modules/house/house.routes.js";
+import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
import { planningRouter } from "./modules/planning/planning.routes.js";
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
import { profileRouter } from "./modules/profile/profile.routes.js";
@@ -38,6 +39,12 @@ export function createServer(): ExpressServer {
server.mountRouter("/auth", authRouter);
server.mountRouter("/house", houseRouter);
+ // Not user-facing — `services/tech-step-llm-worker` only, guarded by
+ // `requireInternalWorker` on every route within (see that router's own
+ // doc comment), never `requireAuth`. Mounted alongside the other routers
+ // rather than nested under one of them since it isn't scoped to a single
+ // recipe/step the way `recipeRouter`'s own correction routes are.
+ server.mountRouter("/internal/tech-steps", techStepWorkerRouter);
server.mountRouter("/planning", planningRouter);
server.mountRouter("/preferences", preferencesRouter);
server.mountRouter("/profile", profileRouter);
diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts
index 7f52193..47a86c4 100644
--- a/apps/api/src/config/env.ts
+++ b/apps/api/src/config/env.ts
@@ -60,6 +60,17 @@ const envSchema = z.object({
.string()
.optional()
.transform((value) => (value === undefined || value === "" ? undefined : value === "true")),
+ /**
+ * Shared secret `services/tech-step-llm-worker` sends as an
+ * `X-Internal-Worker-Secret` header on every call to `/internal/tech-steps/*`
+ * (`requireInternalWorker`, `middlewares/require-internal-worker.ts`).
+ * Optional with no default in the schema itself (unlike `JWT_SECRET`) so
+ * an environment that doesn't run the worker at all (e.g. this repo's
+ * existing test suite) never needs to set it — but `requireInternalWorker`
+ * itself rejects every request outright when it's unset, so the surface
+ * fails closed rather than open if a real deployment forgets to set it.
+ */
+ INTERNAL_WORKER_SECRET: z.string().min(32).optional(),
});
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
diff --git a/apps/api/src/lib/recipe-matching/tech-step-eval-dataset.ts b/apps/api/src/lib/recipe-matching/tech-step-eval-dataset.ts
new file mode 100644
index 0000000..8a0d951
--- /dev/null
+++ b/apps/api/src/lib/recipe-matching/tech-step-eval-dataset.ts
@@ -0,0 +1,263 @@
+/**
+ * Hand-labeled evaluation set for {@link techStepClassifier} — what
+ * `tech-step-eval.test.ts` runs the real classifier against to compute
+ * precision/recall/F1 (`tech-step-evaluator.ts`), the objective gate any
+ * future change to `tech-step-training-data.ts` must clear (see that
+ * module's own doc comment).
+ *
+ * Deliberately *not* reusing `TECH_STEP_TRAINING_DATA`'s own `utterances`
+ * verbatim — scoring the classifier against the exact sentences it was
+ * trained on would measure memorization, not generalization. Every
+ * description below is original phrasing; where a case still needs to name
+ * a technique's own verb to be labeled with confidence (most of them, see
+ * this file's own limits below), it's at least a different sentence shape
+ * than anything in the training corpus.
+ *
+ * `expectedKeys` is a multiset in reading order (see
+ * `TechStepEvalOutcome`'s doc comment in `tech-step-evaluator.ts` for why
+ * order isn't scored but repetition is) of `TechStep.key`s — resolved to
+ * real DB ids and back by `tech-step-eval-runner.ts`, this file only ever
+ * deals in stable keys so it doesn't need DB access to author or read.
+ *
+ * Known limit of this dataset, confirmed against a real run (see
+ * `MIN_OVERALL_F1`'s own doc comment, `tech-step-eval-runner.ts`): most
+ * cases anchor on a technique's own registered synonym, but `_classifyClause`
+ * only falls back to that anchor when the intent classifier's own score is
+ * *below* `CONFIDENCE_THRESHOLD` — a confidently *wrong* whole-clause
+ * classification (e.g. "Blanchissez les haricots verts..." scoring
+ * confidently as `peel` despite the correct `blanch` anchor) overrides the
+ * anchor just as readily as a confidently *right* one does, so this
+ * dataset genuinely does measure real classifier failures, not just a
+ * synthetic floor. A handful of such real mismatches are expected and
+ * intentionally left uncorrected here (see `MIN_OVERALL_F1`'s doc comment)
+ * — fixing the classifier's actual behavior on them is corpus work for a
+ * future change, not something to hide by loosening this dataset's own
+ * expectations to match whatever it currently outputs.
+ */
+
+export interface TechStepEvalCase {
+ description: string;
+ locale: "fr" | "en";
+ expectedKeys: string[];
+}
+
+export const TECH_STEP_EVAL_DATASET: TechStepEvalCase[] = [
+ // --- One straightforward case per technique (fr), covering all 26 ---
+ {
+ description: "Faites cuire les pâtes al dente dans une grande casserole d'eau bien salée.",
+ locale: "fr",
+ expectedKeys: ["cook"],
+ },
+ {
+ description: "Faites bouillir l'eau dans une grande casserole avant d'y plonger les pâtes.",
+ locale: "fr",
+ expectedKeys: ["boil"],
+ },
+ {
+ description: "Plongez les beignets dans l'huile très chaude pour les faire frire.",
+ locale: "fr",
+ expectedKeys: ["fry"],
+ },
+ {
+ description: "Faites fondre le chocolat noir au bain-marie en remuant.",
+ locale: "fr",
+ expectedKeys: ["melt"],
+ },
+ {
+ description: "Déglacez la casserole avec un trait de vinaigre balsamique.",
+ locale: "fr",
+ expectedKeys: ["deglaze"],
+ },
+ {
+ description: "Laissez frémir la sauce tomate vingt minutes à couvert.",
+ locale: "fr",
+ expectedKeys: ["simmer"],
+ },
+ {
+ description: "Faites rôtir la volaille entière sur la broche du four.",
+ locale: "fr",
+ expectedKeys: ["roast"],
+ },
+ {
+ description: "Faites griller les brochettes de poulet quelques minutes de chaque côté.",
+ locale: "fr",
+ expectedKeys: ["grill"],
+ },
+ {
+ description: "Faites sauter les champignons à feu vif dans une poêle très chaude.",
+ locale: "fr",
+ expectedKeys: ["panFry"],
+ },
+ {
+ description: "Blanchissez les haricots verts trois minutes avant de les refroidir.",
+ locale: "fr",
+ expectedKeys: ["blanch"],
+ },
+ {
+ description: "Laissez mariner les brochettes de poulet deux heures au frais.",
+ locale: "fr",
+ expectedKeys: ["marinate"],
+ },
+ {
+ description: "Hachez grossièrement le persil frais avant de le parsemer.",
+ locale: "fr",
+ expectedKeys: ["chop"],
+ },
+ {
+ description: "Épluchez les carottes avant de les couper en rondelles.",
+ locale: "fr",
+ expectedKeys: ["peel"],
+ },
+ {
+ description: "Émincez finement l'échalote pour la vinaigrette.",
+ locale: "fr",
+ expectedKeys: ["mince"],
+ },
+ {
+ description: "Mélangez la farine, le sucre et les œufs dans un grand saladier.",
+ locale: "fr",
+ expectedKeys: ["mix"],
+ },
+ {
+ description: "Fouettez énergiquement la crème jusqu'à ce qu'elle épaississe.",
+ locale: "fr",
+ expectedKeys: ["whisk"],
+ },
+ {
+ description: "Incorporez délicatement la farine tamisée à la préparation.",
+ locale: "fr",
+ expectedKeys: ["foldIn"],
+ },
+ {
+ description: "Réservez la pâte au réfrigérateur pendant que vous préparez la garniture.",
+ locale: "fr",
+ expectedKeys: ["setAside"],
+ },
+ {
+ description: "Assaisonnez le poisson avec du sel, du poivre et un filet de citron.",
+ locale: "fr",
+ expectedKeys: ["season"],
+ },
+ {
+ description: "Égouttez soigneusement le riz dans une passoire fine.",
+ locale: "fr",
+ expectedKeys: ["drain"],
+ },
+ {
+ description:
+ "Faites dorer les morceaux de veau sur toutes leurs faces avant de mouiller avec le bouillon.",
+ locale: "fr",
+ expectedKeys: ["brown"],
+ },
+ {
+ description: "Laissez reposer la viande dix minutes avant de la trancher.",
+ locale: "fr",
+ expectedKeys: ["rest"],
+ },
+ {
+ description: "Préchauffez le four à 200 degrés avant d'y glisser le gratin.",
+ locale: "fr",
+ expectedKeys: ["preheat"],
+ },
+ {
+ description: "Enfournez la tarte pendant trente-cinq minutes jusqu'à ce qu'elle soit dorée.",
+ locale: "fr",
+ expectedKeys: ["bake"],
+ },
+ {
+ description: "Dressez harmonieusement les légumes autour de la pièce de viande.",
+ locale: "fr",
+ expectedKeys: ["plate"],
+ },
+ {
+ description: "Nappez le fond du moule d'une fine couche de caramel.",
+ locale: "fr",
+ expectedKeys: ["coat"],
+ },
+
+ // --- English coverage (same technique verbs, distinct sentences) ---
+ {
+ description: "Simmer the stock gently for forty minutes, skimming occasionally.",
+ locale: "en",
+ expectedKeys: ["simmer"],
+ },
+ {
+ description: "Peel the potatoes and rinse them under cold water.",
+ locale: "en",
+ expectedKeys: ["peel"],
+ },
+ {
+ description: "Whisk the eggs with a pinch of salt until frothy.",
+ locale: "en",
+ expectedKeys: ["whisk"],
+ },
+ {
+ description: "Season the soup generously with black pepper before serving.",
+ locale: "en",
+ expectedKeys: ["season"],
+ },
+ {
+ description: "Make sure the chicken is cooked through before serving.",
+ locale: "en",
+ expectedKeys: ["cook"],
+ },
+
+ // --- Multi-technique sentences, in reading order ---
+ {
+ description: "Préchauffez le four, puis faites rôtir le poulet pendant une heure.",
+ locale: "fr",
+ expectedKeys: ["preheat", "roast"],
+ },
+ {
+ description: "Faites revenir les oignons, puis déglacez la poêle avec du vin blanc.",
+ locale: "fr",
+ expectedKeys: ["brown", "deglaze"],
+ },
+ {
+ description:
+ "Faites cuire les légumes à la vapeur, puis assaisonnez-les avec des herbes fraîches.",
+ locale: "fr",
+ expectedKeys: ["cook", "season"],
+ },
+ {
+ description:
+ "Émincez l'oignon, faites-le suer, puis mouillez avec le bouillon et laissez mijoter.",
+ locale: "fr",
+ expectedKeys: ["mince", "simmer"],
+ },
+
+ // --- No technique mentioned at all ---
+ {
+ description: "Répartissez les convives autour de la table avant de commencer le repas.",
+ locale: "fr",
+ expectedKeys: [],
+ },
+ {
+ description: "Rangez les couverts propres dans le tiroir de la cuisine.",
+ locale: "fr",
+ expectedKeys: [],
+ },
+ {
+ description: "Take the dishes and glasses out of the cupboard.",
+ locale: "en",
+ expectedKeys: [],
+ },
+
+ // --- Documented false-positive traps, re-verified with fresh wording ---
+ // `brown`'s EN synonyms are verb forms only ("browned"/"browning"), not
+ // bare "brown" — precisely so this doesn't false-positive (see that
+ // entry's own comment in tech-step-training-data.ts).
+ {
+ description: "This recipe calls for two tablespoons of brown sugar.",
+ locale: "en",
+ expectedKeys: [],
+ },
+ // `rest`'s EN synonyms are anchored phrases ("let it rest"/"resting
+ // for"...), not bare "rest" — so a sentence using the word in its
+ // "remainder" sense must not anchor `rest` at all.
+ {
+ description: "There is no time to rest before the guests arrive.",
+ locale: "en",
+ expectedKeys: [],
+ },
+];
diff --git a/apps/api/src/lib/recipe-matching/tech-step-eval-runner.ts b/apps/api/src/lib/recipe-matching/tech-step-eval-runner.ts
new file mode 100644
index 0000000..d095251
--- /dev/null
+++ b/apps/api/src/lib/recipe-matching/tech-step-eval-runner.ts
@@ -0,0 +1,71 @@
+import { prisma } from "../../db/prisma.js";
+import { TECH_STEP_EVAL_DATASET } from "./tech-step-eval-dataset.js";
+import {
+ computeTechStepMetrics,
+ type TechStepEvalOutcome,
+ type TechStepEvalResult,
+} from "./tech-step-evaluator.js";
+import { techStepClassifier } from "./tech-step-matcher.js";
+
+/**
+ * Regression floor (not a target) both `runTechStepEvalSuite`'s consumers
+ * gate on — exported from here (not defined separately in each consumer)
+ * so the CI regression gate and `scripts/retrain-tech-steps.ts`'s
+ * pre-backfill gate can never silently drift to different thresholds.
+ *
+ * Calibrated against a real run: the classifier trained on the corpus as
+ * of this constant's introduction scored **0.815** aggregate F1
+ * (33 TP / 9 FP / 6 FN) against `TECH_STEP_EVAL_DATASET` — `0.8` leaves a
+ * small margin below that for run-to-run noise while still catching a
+ * real regression (not a floor picked blind before ever running this
+ * suite — see this feature's plan document for that earlier state). The
+ * mismatches this run surfaced (e.g. "Blanchissez les haricots verts..."
+ * misclassified as `peel`, a handful of anchor-less sentences expected to
+ * match nothing instead scoring confidently as some technique) are real,
+ * known classifier weaknesses — evidence this harness is doing its job,
+ * not something to quietly paper over by loosening the dataset's own
+ * expectations. Improving them is corpus work for a future change, gated
+ * by this same suite.
+ */
+export const MIN_OVERALL_F1 = 0.8;
+
+/**
+ * Runs {@link TECH_STEP_EVAL_DATASET} against the real, currently-trained
+ * `techStepClassifier` and returns the aggregate/per-technique metrics
+ * (`computeTechStepMetrics`, `tech-step-evaluator.ts`) — the one place this
+ * DB-touching "resolve ids to keys, then score" logic lives, shared by
+ * `test/recipe-matching/tech-step-eval.test.ts` (this feature's CI
+ * regression gate) and `scripts/retrain-tech-steps.ts` (the same gate, run
+ * by a maintainer before applying a corpus change). Kept out of
+ * `tech-step-evaluator.ts` itself, which is deliberately pure/DB-free (see
+ * that module's own doc comment) so its scoring logic stays unit-testable
+ * without a database.
+ */
+export async function runTechStepEvalSuite(): Promise {
+ const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
+ const keyById = new Map(techSteps.map((techStep) => [techStep.id, techStep.key]));
+
+ const outcomes: TechStepEvalOutcome[] = [];
+ for (const evalCase of TECH_STEP_EVAL_DATASET) {
+ const techStepIds = await techStepClassifier.matchTechSteps(
+ evalCase.description,
+ evalCase.locale,
+ );
+ const actualKeys = techStepIds.map((id) => {
+ const key = keyById.get(id);
+ // A `techStepId` the classifier resolved that isn't in the seeded
+ // catalog would be a bug in the classifier or the seed data, not
+ // this dataset — fail loudly rather than silently dropping it (see
+ // `_train`'s own comment in `tech-step-matcher.ts` on the
+ // equivalent, deliberately silent `undefined` case it has to
+ // tolerate for a different reason).
+ if (key === undefined) {
+ throw new Error(`Unknown TechStep id ${id} returned for "${evalCase.description}"`);
+ }
+ return key;
+ });
+ outcomes.push({ expectedKeys: evalCase.expectedKeys, actualKeys });
+ }
+
+ return computeTechStepMetrics(outcomes);
+}
diff --git a/apps/api/src/lib/recipe-matching/tech-step-evaluator.ts b/apps/api/src/lib/recipe-matching/tech-step-evaluator.ts
new file mode 100644
index 0000000..6158189
--- /dev/null
+++ b/apps/api/src/lib/recipe-matching/tech-step-evaluator.ts
@@ -0,0 +1,134 @@
+/**
+ * Precision/recall/F1 for {@link techStepClassifier}'s output against a
+ * hand-labeled evaluation set (`tech-step-eval-dataset.ts`) — the objective
+ * counterpart to the "inspected by eye" verdict every corpus change used to
+ * get before this module existed. Every future edit to
+ * `tech-step-training-data.ts` (including the LLM-assisted suggestions the
+ * worker in `services/tech-step-llm-worker` proposes) is expected to run
+ * through `tech-step-eval.test.ts`'s regression gate, which calls
+ * {@link computeTechStepMetrics} — a corpus change that raises recall on one
+ * technique but silently tanks another's precision should fail loudly here,
+ * not get merged on the strength of a few manually-checked examples.
+ *
+ * Pure (no DB/model access) so it's unit-testable on its own — same
+ * convention as `tech-step-matcher.ts`'s own pure helpers (`normalizeText`,
+ * `splitIntoClauses`): this module only ever receives already-resolved
+ * `TechStep.key` strings, never DB ids or a live classifier instance, so it
+ * has nothing to mock to test.
+ */
+
+/** True/false-positive/negative counts for one technique (or the aggregate across all of them), plus the precision/recall/F1 derived from them. */
+export interface TechStepMetrics {
+ truePositives: number;
+ falsePositives: number;
+ falseNegatives: number;
+ precision: number;
+ recall: number;
+ f1: number;
+}
+
+/**
+ * One evaluation case's outcome — what {@link TechStepEvalCase.expectedKeys}
+ * said should be found, against what the classifier actually returned for
+ * that case (already mapped from `TechStepMatch.techStepId` back to
+ * `TechStep.key`, see `tech-step-eval.test.ts`).
+ *
+ * Both lists are *multisets*, not sets — a description that names the same
+ * technique twice (rare, but not impossible: "faire cuire, puis... remettre
+ * à cuire") is expected to produce two matches, and comparing as plain sets
+ * would silently treat a classifier that only found one of them as a
+ * perfect match.
+ */
+export interface TechStepEvalOutcome {
+ expectedKeys: string[];
+ actualKeys: string[];
+}
+
+/** {@link computeTechStepMetrics}'s result — the aggregate across every case, plus a breakdown per technique so a regression hiding behind a healthy overall F1 (one technique's recall collapsing, offset by another's improving) is still visible. */
+export interface TechStepEvalResult {
+ overall: TechStepMetrics;
+ byKey: Record;
+}
+
+interface RawCounts {
+ tp: number;
+ fp: number;
+ fn: number;
+}
+
+function emptyCounts(): RawCounts {
+ return { tp: 0, fp: 0, fn: 0 };
+}
+
+/** Counts occurrences of each key in a multiset, e.g. `["cook", "cook", "bake"]` -> `{cook: 2, bake: 1}`. */
+function countByKey(keys: string[]): Map {
+ const counts = new Map();
+ for (const key of keys) {
+ counts.set(key, (counts.get(key) ?? 0) + 1);
+ }
+ return counts;
+}
+
+/**
+ * Standard vacuous-truth convention for the `0/0` cases: precision defaults
+ * to `1` when nothing was predicted for a key (`tp + fp === 0` — no false
+ * accusation to be precise about), recall defaults to `1` when nothing was
+ * expected (`tp + fn === 0` — nothing to have missed). Neither inflates F1
+ * on its own: a technique the classifier fully misses still has `recall =
+ * 0` (there *were* expected occurrences, just none matched), which is what
+ * pulls F1 down to `0` for that case regardless of precision's vacuous `1`.
+ */
+function toMetrics(counts: RawCounts): TechStepMetrics {
+ const { tp, fp, fn } = counts;
+ const precision = tp + fp === 0 ? 1 : tp / (tp + fp);
+ const recall = tp + fn === 0 ? 1 : tp / (tp + fn);
+ const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
+ return { truePositives: tp, falsePositives: fp, falseNegatives: fn, precision, recall, f1 };
+}
+
+/**
+ * Aggregates every {@link TechStepEvalOutcome} into one overall
+ * precision/recall/F1 plus a per-technique breakdown.
+ *
+ * Counted key by key, multiset-style, per outcome: for a given technique,
+ * `min(expectedCount, actualCount)` true positives, any actual occurrences
+ * beyond that are false positives, any expected occurrences short of that
+ * are false negatives — generalizes the usual set-based TP/FP/FN definition
+ * to handle a technique mentioned (or matched) more than once in the same
+ * step without over- or under-counting it.
+ */
+export function computeTechStepMetrics(outcomes: TechStepEvalOutcome[]): TechStepEvalResult {
+ const overallCounts = emptyCounts();
+ const countsByKey = new Map();
+
+ for (const outcome of outcomes) {
+ const expectedCounts = countByKey(outcome.expectedKeys);
+ const actualCounts = countByKey(outcome.actualKeys);
+ const allKeys = new Set([...expectedCounts.keys(), ...actualCounts.keys()]);
+
+ for (const key of allKeys) {
+ const expected = expectedCounts.get(key) ?? 0;
+ const actual = actualCounts.get(key) ?? 0;
+ const tp = Math.min(expected, actual);
+ const fp = Math.max(0, actual - expected);
+ const fn = Math.max(0, expected - actual);
+
+ overallCounts.tp += tp;
+ overallCounts.fp += fp;
+ overallCounts.fn += fn;
+
+ const keyCounts = countsByKey.get(key) ?? emptyCounts();
+ keyCounts.tp += tp;
+ keyCounts.fp += fp;
+ keyCounts.fn += fn;
+ countsByKey.set(key, keyCounts);
+ }
+ }
+
+ const byKey: Record = {};
+ for (const [key, counts] of countsByKey) {
+ byKey[key] = toMetrics(counts);
+ }
+
+ return { overall: toMetrics(overallCounts), byKey };
+}
diff --git a/apps/api/src/lib/recipe-matching/tech-step-matcher.ts b/apps/api/src/lib/recipe-matching/tech-step-matcher.ts
index 9f7b580..9bfff45 100644
--- a/apps/api/src/lib/recipe-matching/tech-step-matcher.ts
+++ b/apps/api/src/lib/recipe-matching/tech-step-matcher.ts
@@ -252,7 +252,32 @@ export function splitIntoClauses(
* — `0.75` sits comfortably above the noise floor and below every genuine
* match seen so far.
*/
-const CONFIDENCE_THRESHOLD = 0.75;
+export const CONFIDENCE_THRESHOLD = 0.75;
+
+/**
+ * One clause's full classification detail — the finer-grained sibling of
+ * {@link TechStepMatch}, exposing the raw intent/score
+ * `TechStepClassifierService`'s private `_classifyClause` normally
+ * collapses into a single accepted-or-fallback verdict. Nothing on the
+ * interactive save/read path needs this (that's exactly what
+ * `_classifyClause`'s threshold + fallback logic is for) — it exists for
+ * `services/tech-step-llm-worker`'s "audit low-confidence clauses" job
+ * (`modules/internal/tech-step-worker.service.ts`'s `getAuditBatch`), which
+ * needs to see *which* clauses the classifier itself wasn't sure about, not
+ * just its final best-effort verdict.
+ */
+export interface TechStepClauseClassification {
+ /** The clause's own text (`description.slice(start, end)`, trimmed). */
+ clauseText: string;
+ start: number;
+ end: number;
+ /** The clause's NER anchor's own implied technique `uid`, if it had one — same as `TechStepClause.anchor.uid`. */
+ anchorUid: string | null;
+ /** The intent classifier's own top guess for this clause, whatever its score — `null` only when it returned node-nlp's `"None"` sentinel. Unlike {@link TechStepMatch}, never silently replaced by the anchor's uid — the whole point of this type is to expose the classifier's raw opinion, confident or not. */
+ intentUid: string | null;
+ /** The intent classifier's own confidence for `intentUid` — `0` when `intentUid` is `null` (nothing to have a score about). */
+ score: number;
+}
/**
* Trains and owns the `node-nlp` model behind {@link matchTechStepSpans} —
@@ -388,6 +413,66 @@ export class TechStepClassifierService {
}
}
+ /**
+ * Splits `description` into clauses exactly like {@link matchTechStepSpans}
+ * does, but returns each clause's *raw* classification detail
+ * ({@link TechStepClauseClassification}) instead of the threshold-applied,
+ * anchor-fallback-resolved `TechStepMatch` — see that type's doc comment
+ * for why/who needs this. Deliberately a separate traversal rather than a
+ * shared refactor with `matchTechStepSpans`/`_classifyClause`: this method
+ * exists purely to add a new, additive read path without risking a
+ * behavior change to the two already-relied-on methods above.
+ */
+ public async classifyClauses(
+ description: string,
+ locale: string,
+ ): Promise {
+ try {
+ await this._ensureTrained();
+ if (description.trim().length === 0) return [];
+
+ const nerResult = await this._manager.process(locale, description);
+ const candidates: TechniqueCandidate[] = nerResult.entities
+ .filter((entity) => entity.type === "enum")
+ .map((entity) => ({
+ uid: entity.entity,
+ start: entity.start,
+ end: entity.end + 1,
+ }));
+
+ const clauses = splitIntoClauses(description, candidates);
+ const results: TechStepClauseClassification[] = [];
+ for (const clause of clauses) {
+ const clauseText = description.slice(clause.start, clause.end).trim();
+ const anchorUid = clause.anchor?.uid ?? null;
+ if (clauseText.length === 0) {
+ results.push({
+ clauseText,
+ start: clause.start,
+ end: clause.end,
+ anchorUid,
+ intentUid: null,
+ score: 0,
+ });
+ continue;
+ }
+ const result = await this._manager.process(locale, clauseText);
+ const intentUid = result.intent !== "None" ? result.intent : null;
+ results.push({
+ clauseText,
+ start: clause.start,
+ end: clause.end,
+ anchorUid,
+ intentUid,
+ score: intentUid === null ? 0 : result.score,
+ });
+ }
+ return results;
+ } catch (err) {
+ throw err; // see matchTechStepSpans()'s catch comment above
+ }
+ }
+
/**
* Convenience wrapper around {@link matchTechStepSpans} for callers that
* only care about *which* techniques matched, not where — e.g.
diff --git a/apps/api/src/middlewares/require-internal-worker.ts b/apps/api/src/middlewares/require-internal-worker.ts
new file mode 100644
index 0000000..f74535e
--- /dev/null
+++ b/apps/api/src/middlewares/require-internal-worker.ts
@@ -0,0 +1,50 @@
+import { timingSafeEqual } from "node:crypto";
+import { HttpError } from "@batch-cooking/error-tools";
+import { ErrorCode } from "@batch-cooking/shared";
+import type { NextFunction, Request, Response } from "express";
+import { env } from "../config/env.js";
+
+/** Header `services/tech-step-llm-worker` sends its shared secret on. Not `Authorization`/a bearer scheme — this isn't a user session, just one internal caller authenticating to another, same "one flat shared secret" shape as e.g. a webhook signing header. */
+const INTERNAL_WORKER_SECRET_HEADER = "x-internal-worker-secret";
+
+/**
+ * Express middleware guarding `/internal/tech-steps/*` — the surface
+ * `services/tech-step-llm-worker` (a process outside this monorepo, no
+ * Prisma access of its own, see that service's own README) reads
+ * low-confidence NLP clauses and pending `StepTechStepCorrection`s from,
+ * and posts `TechStepTrainingSuggestion`s back to. Never reachable by an
+ * end user's session cookie — deliberately a *different* auth mechanism
+ * than {@link requireAuth} (`require-auth.ts`), not layered on top of it,
+ * since the worker has no `UserProfile`/session of its own to authenticate
+ * as.
+ *
+ * Fails closed: an unset `INTERNAL_WORKER_SECRET` (the default in any
+ * environment that doesn't run the worker, see `config/env.ts`) rejects
+ * every request rather than leaving the surface open, same posture as a
+ * misconfigured `JWT_SECRET` would if it had a working fallback.
+ *
+ * @throws {HttpError} `401 NOT_AUTHENTICATED` if the header is missing,
+ * wrong, or the server has no secret configured at all — never
+ * distinguishes the reason, same posture as {@link requireAuth}.
+ */
+export function requireInternalWorker(req: Request, _res: Response, next: NextFunction): void {
+ const provided = req.header(INTERNAL_WORKER_SECRET_HEADER);
+ if (env.INTERNAL_WORKER_SECRET === undefined || provided === undefined) {
+ next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
+ return;
+ }
+
+ // `timingSafeEqual` throws on mismatched buffer lengths rather than
+ // returning `false` — checked separately first. A length mismatch alone
+ // already means "not equal", so this loses no timing-attack protection
+ // (an attacker learns nothing beyond what a differing length itself
+ // already reveals, no different from `!==` on the common case where the
+ // secret's real length isn't a secret worth protecting).
+ const expected = Buffer.from(env.INTERNAL_WORKER_SECRET);
+ const actual = Buffer.from(provided);
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
+ next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
+ return;
+ }
+ next();
+}
diff --git a/apps/api/src/modules/internal/tech-step-worker.routes.ts b/apps/api/src/modules/internal/tech-step-worker.routes.ts
new file mode 100644
index 0000000..748cd9a
--- /dev/null
+++ b/apps/api/src/modules/internal/tech-step-worker.routes.ts
@@ -0,0 +1,49 @@
+import { wrapAsyncHandler } from "@batch-cooking/express-tools";
+import {
+ auditBatchQuerySchema,
+ submitTrainingSuggestionsSchema,
+ workerBatchQuerySchema,
+} from "@batch-cooking/shared";
+import { Router } from "express";
+import { requireInternalWorker } from "../../middlewares/require-internal-worker.js";
+import {
+ getAuditBatch,
+ getPendingCorrections,
+ submitTrainingSuggestions,
+} from "./tech-step-worker.service.js";
+
+/**
+ * Router mounted at `/internal/tech-steps` in app.ts — every route requires
+ * {@link requireInternalWorker}, never {@link requireAuth}
+ * (`middlewares/require-auth.ts`): this is `services/tech-step-llm-worker`
+ * authenticating as itself, not a user session. See that middleware's own
+ * doc comment for why the two are deliberately separate mechanisms.
+ */
+export const techStepWorkerRouter = Router();
+
+techStepWorkerRouter.get(
+ "/audit-batch",
+ requireInternalWorker,
+ wrapAsyncHandler(async (req, res) => {
+ const input = auditBatchQuerySchema.parse(req.query);
+ res.status(200).json(await getAuditBatch(input.locale, input.limit));
+ }),
+);
+
+techStepWorkerRouter.get(
+ "/pending-corrections",
+ requireInternalWorker,
+ wrapAsyncHandler(async (req, res) => {
+ const input = workerBatchQuerySchema.parse(req.query);
+ res.status(200).json(await getPendingCorrections(input.limit));
+ }),
+);
+
+techStepWorkerRouter.post(
+ "/training-suggestions",
+ requireInternalWorker,
+ wrapAsyncHandler(async (req, res) => {
+ const input = submitTrainingSuggestionsSchema.parse(req.body);
+ res.status(201).json(await submitTrainingSuggestions(input));
+ }),
+);
diff --git a/apps/api/src/modules/internal/tech-step-worker.service.ts b/apps/api/src/modules/internal/tech-step-worker.service.ts
new file mode 100644
index 0000000..607c0f1
--- /dev/null
+++ b/apps/api/src/modules/internal/tech-step-worker.service.ts
@@ -0,0 +1,201 @@
+import { HttpError } from "@batch-cooking/error-tools";
+import {
+ ErrorCode,
+ type PendingTechStepCorrectionView,
+ type SubmitTrainingSuggestionsInput,
+ type TechStepAuditClauseView,
+} from "@batch-cooking/shared";
+import { prisma } from "../../db/prisma.js";
+import {
+ CONFIDENCE_THRESHOLD,
+ techStepClassifier,
+} from "../../lib/recipe-matching/tech-step-matcher.js";
+
+/**
+ * Read/write surface `services/tech-step-llm-worker` calls through
+ * `/internal/tech-steps/*` (`tech-step-worker.routes.ts`, guarded by
+ * `requireInternalWorker`) — the worker has no Prisma client or database
+ * credentials of its own (see that service's own README), so every
+ * corrections/audit-sample read and every suggestion write goes through
+ * here rather than the worker touching this schema directly. Keeps
+ * `apps/api` the single owner of the schema/migrations, and keeps the
+ * worker a pure "read some text, run inference, post a suggestion" process
+ * with nothing to keep in sync if the schema changes shape.
+ */
+
+/**
+ * How many of the most recently created `Step`s {@link getAuditBatch} scans
+ * per call before filtering down to low-confidence clauses — a fixed
+ * recency-biased sample, not every `Step` in the database, to keep this
+ * endpoint's cost bounded regardless of how large the recipe catalog gets.
+ * Recently-added steps are also the steps most likely to still use
+ * vocabulary the training corpus hasn't caught up with yet, which is
+ * exactly what this audit is for. A smarter sampling strategy (e.g.
+ * weighted by how often a recipe is actually viewed/planned) is future
+ * work, not needed for this feature's first version.
+ */
+const AUDIT_SAMPLE_SIZE = 200;
+
+/**
+ * Every low-confidence clause found across a recency-biased sample of
+ * existing `Step`s (see {@link AUDIT_SAMPLE_SIZE}), for
+ * `services/tech-step-llm-worker`'s `audit-low-confidence` job to get a
+ * second opinion on. "Low-confidence" mirrors exactly what
+ * `TechStepClassifierService._classifyClause` itself distrusts (a clause
+ * with an NER anchor but a classifier score under
+ * {@link CONFIDENCE_THRESHOLD}) — the same clauses that pipeline already
+ * has to fall back to keyword-anchor guessing for, not an arbitrary
+ * separate cutoff.
+ */
+export async function getAuditBatch(
+ locale: string,
+ limit: number,
+): Promise {
+ try {
+ const steps = await prisma.step.findMany({
+ orderBy: { id: "desc" },
+ take: AUDIT_SAMPLE_SIZE,
+ select: { id: true, recipeId: true, description: true },
+ });
+
+ const results: TechStepAuditClauseView[] = [];
+ for (const step of steps) {
+ if (results.length >= limit) break;
+ const clauses = await techStepClassifier.classifyClauses(step.description, locale);
+ for (const clause of clauses) {
+ if (results.length >= limit) break;
+ const isLowConfidence = clause.anchorUid !== null && clause.score < CONFIDENCE_THRESHOLD;
+ if (!isLowConfidence) continue;
+ results.push({
+ stepId: step.id,
+ recipeId: step.recipeId,
+ clauseText: clause.clauseText,
+ anchorKey: clause.anchorUid,
+ intentKey: clause.intentUid,
+ score: clause.score,
+ locale,
+ });
+ }
+ }
+ return results;
+ } catch (err) {
+ throw err; // see recipe.service.ts's equivalent catch comment
+ }
+}
+
+/**
+ * Every `StepTechStepCorrection` not yet turned into a
+ * `TechStepTrainingSuggestion` (`consumedAt IS NULL`), oldest first — a
+ * FIFO queue the worker's `transform-corrections` job drains, `limit` at a
+ * time.
+ *
+ * `correctedTechStepId IS NOT NULL` on top of `consumedAt IS NULL`: a
+ * correction that *removes* a match ("no technique belongs here",
+ * `correctedTechStepId: null` — see `StepTechStepCorrection`'s schema doc
+ * comment) has no technique to propose new positive training data *for*.
+ * Surfacing it here would leave it permanently unconsumable (the worker
+ * has nothing to submit a suggestion for, so it would never stamp
+ * `consumedAt`, and it would keep re-appearing in every future batch
+ * forever) — excluded at the source instead, not filtered/skipped
+ * downstream by the worker.
+ */
+export async function getPendingCorrections(
+ limit: number,
+): Promise {
+ try {
+ const corrections = await prisma.stepTechStepCorrection.findMany({
+ where: { consumedAt: null, correctedTechStepId: { not: null } },
+ orderBy: { createdAt: "asc" },
+ take: limit,
+ include: {
+ step: { select: { id: true, recipeId: true, description: true } },
+ previousTechStep: { select: { key: true } },
+ correctedTechStep: { select: { key: true } },
+ },
+ });
+
+ return corrections.map((correction) => ({
+ id: correction.id,
+ stepId: correction.step.id,
+ recipeId: correction.step.recipeId,
+ clauseText: correction.step.description.slice(correction.start, correction.end),
+ start: correction.start,
+ end: correction.end,
+ previousTechStepKey: correction.previousTechStep?.key ?? null,
+ correctedTechStepKey: correction.correctedTechStep?.key ?? null,
+ }));
+ } catch (err) {
+ throw err; // see recipe.service.ts's equivalent catch comment
+ }
+}
+
+/**
+ * Persists a batch of `TechStepTrainingSuggestion`s and, for every
+ * suggestion sourced from a correction, stamps that correction's
+ * `consumedAt` in the same transaction — so a worker run that crashes
+ * partway through never leaves a correction consumed with no matching
+ * suggestion, or a suggestion created against a correction still (wrongly)
+ * eligible to be picked up again by the next run.
+ *
+ * @throws {HttpError} `404 TECH_STEP_NOT_FOUND` if any `techStepKey` in the
+ * batch doesn't match a reference `TechStep` — rejects the *whole* batch
+ * rather than skipping the bad entries, on the theory that a worker
+ * sending an unknown key is more likely a version-skew bug (its own
+ * taxonomy copy, `services/tech-step-llm-worker/src/tech-step-taxonomy.ts`,
+ * drifting from this API's `TechStep` catalog) than a one-off it should
+ * silently tolerate.
+ */
+export async function submitTrainingSuggestions(
+ input: SubmitTrainingSuggestionsInput,
+): Promise<{ created: number }> {
+ try {
+ const techStepKeys = [
+ ...new Set(input.suggestions.map((suggestion) => suggestion.techStepKey)),
+ ];
+ const techSteps = await prisma.techStep.findMany({
+ where: { key: { in: techStepKeys } },
+ select: { id: true, key: true },
+ });
+ const techStepIdByKey = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
+ const missingKeys = techStepKeys.filter((key) => !techStepIdByKey.has(key));
+ if (missingKeys.length > 0) {
+ throw new HttpError(
+ 404,
+ ErrorCode.TECH_STEP_NOT_FOUND,
+ `Unknown techStepKey(s): ${missingKeys.join(", ")}`,
+ );
+ }
+
+ await prisma.$transaction(async (tx) => {
+ for (const suggestion of input.suggestions) {
+ // Non-null by construction — every key in `input.suggestions` was
+ // just confirmed present in `techStepIdByKey` above (the `missingKeys`
+ // check would have thrown otherwise).
+ const techStepId = techStepIdByKey.get(suggestion.techStepKey);
+ if (techStepId === undefined) continue;
+
+ await tx.techStepTrainingSuggestion.create({
+ data: {
+ techStepId,
+ locale: suggestion.locale,
+ suggestedSynonyms: suggestion.suggestedSynonyms,
+ suggestedUtterances: suggestion.suggestedUtterances,
+ sourceType: suggestion.sourceType,
+ sourceCorrectionId: suggestion.sourceCorrectionId ?? null,
+ },
+ });
+
+ if (suggestion.sourceCorrectionId !== null && suggestion.sourceCorrectionId !== undefined) {
+ await tx.stepTechStepCorrection.update({
+ where: { id: suggestion.sourceCorrectionId },
+ data: { consumedAt: new Date() },
+ });
+ }
+ }
+ });
+
+ return { created: input.suggestions.length };
+ } catch (err) {
+ throw err; // see recipe.service.ts's equivalent catch comment
+ }
+}
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
new file mode 100644
index 0000000..6ae000e
--- /dev/null
+++ b/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts
@@ -0,0 +1,323 @@
+import { HttpError } from "@batch-cooking/error-tools";
+import {
+ ErrorCode,
+ type StepTechStepCorrectionView,
+ type SubmitTechStepCorrectionInput,
+ type SubmitTechStepCorrectionResult,
+} from "@batch-cooking/shared";
+import type { Prisma } from "@prisma/client";
+import { prisma } from "../../db/prisma.js";
+import { assertRecipeVisible, toStepTechStepViews } from "./recipe.service.js";
+
+/**
+ * User-submitted corrections to a step's detected techniques
+ * (`StepTechStepCorrection` in schema.prisma) — kept in its own module
+ * rather than folded into `recipe.service.ts`, same "one file per concern"
+ * split that file itself follows for `tech-step-matcher.ts`. Deliberately
+ * open to *any* viewer who can see the recipe, not just its author (unlike
+ * every write path in `recipe.service.ts`, which uses `assertIsAuthor`) —
+ * correcting a mislabeled technique isn't editing the recipe's own
+ * content, and restricting it to authors would starve the training-data
+ * feedback loop (`services/tech-step-llm-worker`) of the volume it needs.
+ */
+
+type CorrectionWithTechSteps = Prisma.StepTechStepCorrectionGetPayload<{
+ include: { previousTechStep: true; correctedTechStep: true };
+}>;
+
+const correctionInclude = {
+ previousTechStep: true,
+ correctedTechStep: true,
+} 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.
+ */
+async function loadVisibleStepOrThrow(
+ recipeId: number,
+ stepId: number,
+ viewerId: number,
+ viewerHouseId: number | null,
+): Promise<{ id: number; descriptionLength: number }> {
+ try {
+ const step = await prisma.step.findUnique({
+ where: { id: stepId },
+ select: { id: true, recipeId: true, description: true },
+ });
+ if (!step || step.recipeId !== recipeId) {
+ 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 };
+ } catch (err) {
+ throw err; // see recipe.service.ts's equivalent catch comment
+ }
+}
+
+/** Throws `404 TECH_STEP_NOT_FOUND` if any id in `ids` doesn't match a reference `TechStep` row — same shape as `recipe.service.ts`'s `assertIngredientsExist`/`assertUnitsExist` for the recipe payload's own reference ids. */
+async function assertTechStepsExist(ids: number[]): Promise {
+ try {
+ if (ids.length === 0) return;
+ const found = await prisma.techStep.findMany({
+ where: { id: { in: ids } },
+ select: { id: true },
+ });
+ const foundIds = new Set(found.map((techStep) => techStep.id));
+ const missing = ids.filter((id) => !foundIds.has(id));
+ if (missing.length > 0) {
+ throw new HttpError(
+ 404,
+ ErrorCode.TECH_STEP_NOT_FOUND,
+ `TechStep ids not found: ${missing.join(", ")}`,
+ );
+ }
+ } catch (err) {
+ throw err; // see loadVisibleStepOrThrow's catch comment
+ }
+}
+
+/**
+ * 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 {
+ 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 {
+ 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 {
+ return {
+ id: correction.id,
+ start: correction.start,
+ end: correction.end,
+ previousTechStep: correction.previousTechStep
+ ? { id: correction.previousTechStep.id, key: correction.previousTechStep.key }
+ : null,
+ correctedTechStep: correction.correctedTechStep
+ ? { id: correction.correctedTechStep.id, key: correction.correctedTechStep.key }
+ : null,
+ createdAt: correction.createdAt.toISOString(),
+ };
+}
+
+/**
+ * Records one correction to `stepId`'s detected techniques, submitted by
+ * `correctorId`, and immediately applies its effect to the step's real
+ * `StepTechStep` sequence (a `"manual"`-tagged row — see
+ * {@link applyManualCorrection}) — see
+ * {@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
+ * {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
+ * `start`/`end` fall outside the step's current `description` (it may
+ * have been edited since the user last saw it). `404 TECH_STEP_NOT_FOUND`
+ * if either tech-step id doesn't exist.
+ */
+export async function submitTechStepCorrection(
+ recipeId: number,
+ stepId: number,
+ input: SubmitTechStepCorrectionInput,
+ correctorId: number,
+ viewerHouseId: number | null,
+): Promise {
+ try {
+ const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
+
+ if (input.start >= step.descriptionLength || input.end > step.descriptionLength) {
+ throw new HttpError(
+ 400,
+ ErrorCode.INVALID_CORRECTION_SPAN,
+ `Span [${input.start}, ${input.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
+ );
+ }
+
+ const techStepIds = [input.previousTechStepId, input.correctedTechStepId].filter(
+ (id): id is number => id !== null && id !== undefined,
+ );
+ await assertTechStepsExist(techStepIds);
+
+ const { correction, techSteps } = await prisma.$transaction(async (tx) => {
+ const createdCorrection = await tx.stepTechStepCorrection.create({
+ data: {
+ stepId: step.id,
+ correctorId,
+ start: input.start,
+ end: input.end,
+ previousTechStepId: input.previousTechStepId ?? null,
+ correctedTechStepId: input.correctedTechStepId ?? null,
+ },
+ 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 { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
+ } catch (err) {
+ throw err; // see loadVisibleStepOrThrow's catch comment
+ }
+}
+
+/**
+ * Every correction submitted so far for `stepId`, most recent first —
+ * mainly useful for a user checking what's already been submitted (by
+ * anyone) for a span before adding another (see `StepTechStepCorrectionView`'s
+ * doc comment, `packages/shared`).
+ *
+ * @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see {@link loadVisibleStepOrThrow}.
+ */
+export async function listTechStepCorrections(
+ recipeId: number,
+ stepId: number,
+ viewerId: number,
+ viewerHouseId: number | null,
+): Promise {
+ try {
+ const step = await loadVisibleStepOrThrow(recipeId, stepId, viewerId, viewerHouseId);
+ const corrections = await prisma.stepTechStepCorrection.findMany({
+ where: { stepId: step.id },
+ orderBy: { createdAt: "desc" },
+ include: correctionInclude,
+ });
+ return corrections.map(toCorrectionView);
+ } catch (err) {
+ throw err; // see loadVisibleStepOrThrow's catch comment
+ }
+}
diff --git a/apps/api/src/modules/recipe/recipe.routes.ts b/apps/api/src/modules/recipe/recipe.routes.ts
index 669bdc7..f7fa8fd 100644
--- a/apps/api/src/modules/recipe/recipe.routes.ts
+++ b/apps/api/src/modules/recipe/recipe.routes.ts
@@ -4,6 +4,7 @@ import {
createRecipeSchema,
ErrorCode,
listRecipesSchema,
+ submitTechStepCorrectionSchema,
updateRecipeSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
@@ -17,6 +18,10 @@ import {
removeFavorite,
updateRecipe,
} from "./recipe.service.js";
+import {
+ listTechStepCorrections,
+ submitTechStepCorrection,
+} from "./recipe-tech-step-correction.service.js";
/** Router mounted at `/recipes` in app.ts. Every route requires a session — the catalog is shared across households, not public (same reasoning as `planning`/`house`: it's app content, not signup-time reference data). */
export const recipeRouter = Router();
@@ -30,6 +35,15 @@ function parseRecipeId(rawId: string | undefined): number {
return id;
}
+/** Same shape as {@link parseRecipeId}, for the `:stepId` route param of the tech-step-correction routes below — a distinct function only so the error message names the right param. */
+function parseStepId(rawId: string | undefined): number {
+ const id = Number(rawId);
+ if (!Number.isInteger(id)) {
+ throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "stepId must be an integer");
+ }
+ return id;
+}
+
recipeRouter.get(
"/",
requireAuth,
@@ -109,3 +123,29 @@ recipeRouter.delete(
res.status(204).end();
}),
);
+
+// Open to any authenticated viewer who can see the recipe, not just its
+// author — see recipe-tech-step-correction.service.ts's own doc comment
+// for why.
+recipeRouter.post(
+ "/:id/steps/:stepId/corrections",
+ requireAuth,
+ wrapAsyncHandler(async (req, res) => {
+ const id = parseRecipeId(req.params.id);
+ const stepId = parseStepId(req.params.stepId);
+ const input = submitTechStepCorrectionSchema.parse(req.body);
+ const { id: correctorId, houseId } = res.locals.userProfile;
+ res.status(201).json(await submitTechStepCorrection(id, stepId, input, correctorId, houseId));
+ }),
+);
+
+recipeRouter.get(
+ "/:id/steps/:stepId/corrections",
+ requireAuth,
+ wrapAsyncHandler(async (req, res) => {
+ const id = parseRecipeId(req.params.id);
+ const stepId = parseStepId(req.params.stepId);
+ const { id: viewerId, houseId } = res.locals.userProfile;
+ res.status(200).json(await listTechStepCorrections(id, stepId, viewerId, houseId));
+ }),
+);
diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts
index ea6df3b..86d9697 100644
--- a/apps/api/src/modules/recipe/recipe.service.ts
+++ b/apps/api/src/modules/recipe/recipe.service.ts
@@ -132,18 +132,30 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
* existed) still has a perfectly good match to show, just without the
* 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".
+ *
+ * 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"],
): StepTechStepView[] {
const views: StepTechStepView[] = [];
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;
views.push({
techStep: { id: techStep.id, key: techStep.key },
start,
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 } : {}),
});
}
diff --git a/apps/api/src/modules/sources/sources.service.ts b/apps/api/src/modules/sources/sources.service.ts
index 36e8a45..0a93f78 100644
--- a/apps/api/src/modules/sources/sources.service.ts
+++ b/apps/api/src/modules/sources/sources.service.ts
@@ -225,6 +225,12 @@ export async function previewSourceItem(
end: match.end,
contextStart: match.contextStart,
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",
},
]
: [];
diff --git a/apps/api/src/scripts/backfill-tech-steps.ts b/apps/api/src/scripts/backfill-tech-steps.ts
index 519f107..5facc3e 100644
--- a/apps/api/src/scripts/backfill-tech-steps.ts
+++ b/apps/api/src/scripts/backfill-tech-steps.ts
@@ -1,60 +1,123 @@
+import { pathToFileURL } from "node:url";
import { prisma } from "../db/prisma.js";
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
+import { renumberStepTechSteps } from "../modules/recipe/recipe-tech-step-correction.service.js";
/**
- * One-off maintenance script: recomputes every existing `Step`'s
- * `StepTechStep` sequence against the *current* classifier
+ * Recomputes every existing `Step`'s `"auto"`-sourced `StepTechStep`
+ * entries against the *current* classifier
* (`tech-step-matcher.ts`/`tech-step-training-data.ts`), the same way
* `updateRecipe` does when a user resaves a recipe through the UI —
* 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 guessing).
+ * no persisted per-recipe locale to recover for a step that already
+ * exists, so this matches real resave behavior exactly rather than
+ * guessing).
*
* Needed because tech-step detection only ever runs at create/update time
* (`recipe.service.ts`'s `matchStepsTechSteps`), never retroactively — a
* step saved before a classifier/corpus change (new vocabulary, or the
- * `contextStart`/`contextEnd` columns this same session added) keeps
- * whatever it was matched with at the time until it's next resaved. Run
- * this after a corpus change to bring every existing step in sync without
- * asking users to open and resave every recipe by hand:
+ * `contextStart`/`contextEnd` columns a previous session added) keeps
+ * whatever it was matched with at the time until it's next resaved.
*
- * pnpm --filter api exec tsx src/scripts/backfill-tech-steps.ts
+ * `"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.
*
- * Safe to re-run: each step's technique sequence is fully replaced (delete
- * + recreate) from the classifier's current output, same as a real edit —
- * running it twice in a row with no corpus change in between is a no-op.
+ * 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
+ * maintainer workflow, without shelling out to a second process.
+ *
+ * Safe to re-run: with no manual entries and no corpus change since the
+ * last run, this is a no-op (the same `"auto"` matches get deleted and
+ * recreated identically); with manual entries present, they're preserved
+ * on every run by construction.
*/
-async function backfillTechSteps(): Promise {
+export async function backfillTechSteps(): Promise<{ total: number; changed: number }> {
const steps = await prisma.step.findMany({ select: { id: true, description: true } });
console.info(`Recomputing tech steps for ${steps.length} step(s)...`);
let changed = 0;
for (const step of steps) {
const matches = await techStepClassifier.matchTechStepSpans(step.description, "fr");
- await prisma.$transaction([
- prisma.stepTechStep.deleteMany({ where: { stepId: step.id } }),
- prisma.stepTechStep.createMany({
- data: matches.map((match, order) => ({
- stepId: step.id,
- techStepId: match.techStepId,
- order,
- start: match.start,
- end: match.end,
- contextStart: match.contextStart,
- contextEnd: match.contextEnd,
- })),
- }),
- ]);
+
+ await prisma.$transaction(async (tx) => {
+ const manualRows = await tx.stepTechStep.findMany({
+ where: { stepId: step.id, source: "manual" },
+ });
+
+ const nonOverlappingMatches = matches.filter(
+ (match) =>
+ !manualRows.some(
+ (manual) =>
+ 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;
}
console.info(`Done — ${changed} step(s) recomputed.`);
+ return { total: steps.length, changed };
}
-backfillTechSteps()
- .then(() => prisma.$disconnect())
- .catch(async (err) => {
- console.error(err);
- await prisma.$disconnect();
- process.exit(1);
- });
+// Only runs when this file is executed directly (`tsx
+// src/scripts/backfill-tech-steps.ts`), not when `retrain-tech-steps.ts`
+// imports `backfillTechSteps` above — the standard ESM "is this the entry
+// module" check, first needed in this codebase by that new script; every
+// prior script here (`seed-runtime.ts`) was always only ever run directly,
+// never imported. `pathToFileURL` (not a naive `` `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) {
+ backfillTechSteps()
+ .then(() => prisma.$disconnect())
+ .catch(async (err) => {
+ console.error(err);
+ await prisma.$disconnect();
+ process.exit(1);
+ });
+}
diff --git a/apps/api/src/scripts/list-pending-training-suggestions.ts b/apps/api/src/scripts/list-pending-training-suggestions.ts
new file mode 100644
index 0000000..17089a3
--- /dev/null
+++ b/apps/api/src/scripts/list-pending-training-suggestions.ts
@@ -0,0 +1,71 @@
+import { prisma } from "../db/prisma.js";
+
+/**
+ * Maintainer-facing report of every `TechStepTrainingSuggestion` still
+ * `status: "pending"` (`TechStepTrainingSuggestion`'s own schema doc
+ * comment) — generated by `services/tech-step-llm-worker`'s scheduled
+ * jobs, from either a user correction or the worker's own low-confidence
+ * audit (`sourceType`). What a maintainer reads *before* hand-editing
+ * `tech-step-training-data.ts` and running `retrain-tech-steps.ts` — this
+ * script never writes anything, purely a read-only report to stdout:
+ *
+ * pnpm --filter api exec tsx src/scripts/list-pending-training-suggestions.ts
+ *
+ * Grouped by technique key so every suggestion for the same entry in
+ * `TECH_STEP_TRAINING_DATA` is read together, matching how that file
+ * itself is organized (one block per technique).
+ */
+async function listPendingTrainingSuggestions(): Promise {
+ const suggestions = await prisma.techStepTrainingSuggestion.findMany({
+ where: { status: "pending" },
+ orderBy: [{ techStepId: "asc" }, { createdAt: "asc" }],
+ include: { techStep: { select: { key: true } } },
+ });
+
+ if (suggestions.length === 0) {
+ console.info("No pending training suggestions.");
+ return;
+ }
+
+ const byTechStepKey = new Map();
+ for (const suggestion of suggestions) {
+ const key = suggestion.techStep.key;
+ const group = byTechStepKey.get(key);
+ if (group) {
+ group.push(suggestion);
+ } else {
+ byTechStepKey.set(key, [suggestion]);
+ }
+ }
+
+ const lines: string[] = [`# Pending tech-step training suggestions (${suggestions.length})`, ""];
+ for (const [techStepKey, group] of byTechStepKey) {
+ lines.push(`## ${techStepKey}`, "");
+ for (const suggestion of group) {
+ const source =
+ suggestion.sourceCorrectionId !== null
+ ? `${suggestion.sourceType} (correction #${suggestion.sourceCorrectionId})`
+ : suggestion.sourceType;
+ lines.push(`- id ${suggestion.id} · locale ${suggestion.locale} · source: ${source}`);
+ if (suggestion.suggestedSynonyms.length > 0) {
+ lines.push(` - synonyms: ${suggestion.suggestedSynonyms.join(", ")}`);
+ }
+ if (suggestion.suggestedUtterances.length > 0) {
+ lines.push(
+ ` - utterances: ${suggestion.suggestedUtterances.map((u) => `"${u}"`).join(", ")}`,
+ );
+ }
+ }
+ lines.push("");
+ }
+
+ console.info(lines.join("\n"));
+}
+
+listPendingTrainingSuggestions()
+ .then(() => prisma.$disconnect())
+ .catch(async (err) => {
+ console.error(err);
+ await prisma.$disconnect();
+ process.exit(1);
+ });
diff --git a/apps/api/src/scripts/retrain-tech-steps.ts b/apps/api/src/scripts/retrain-tech-steps.ts
new file mode 100644
index 0000000..0e870e7
--- /dev/null
+++ b/apps/api/src/scripts/retrain-tech-steps.ts
@@ -0,0 +1,98 @@
+import { prisma } from "../db/prisma.js";
+import {
+ MIN_OVERALL_F1,
+ runTechStepEvalSuite,
+} from "../lib/recipe-matching/tech-step-eval-runner.js";
+import { backfillTechSteps } from "./backfill-tech-steps.js";
+
+/** Parses `--applied=1,2,3`/`--rejected=4,5` from argv into id arrays — both optional, both empty by default (a run with neither flag only re-gates + backfills, doesn't touch any suggestion's status). */
+function parseSuggestionIds(flag: "applied" | "rejected"): number[] {
+ const prefix = `--${flag}=`;
+ const arg = process.argv.find((value) => value.startsWith(prefix));
+ if (arg === undefined) return [];
+ return arg
+ .slice(prefix.length)
+ .split(",")
+ .map((value) => value.trim())
+ .filter((value) => value.length > 0)
+ .map((value) => {
+ const id = Number(value);
+ if (!Number.isInteger(id)) {
+ throw new Error(`--${flag}: "${value}" is not a valid integer id`);
+ }
+ return id;
+ });
+}
+
+/**
+ * Maintainer workflow closing the loop on a training-corpus change (see
+ * this feature's plan document):
+ *
+ * 1. A maintainer has already hand-edited `tech-step-training-data.ts`
+ * (informed by `list-pending-training-suggestions.ts`'s report), and
+ * decided which `TechStepTrainingSuggestion` ids they incorporated
+ * (`--applied=`) or explicitly discarded (`--rejected=`).
+ * 2. This script re-runs the F1 regression gate
+ * ({@link runTechStepEvalSuite} against {@link MIN_OVERALL_F1}) —
+ * refuses to backfill at all if the edited corpus scores worse than
+ * the floor, so a bad edit never reaches every existing recipe.
+ * 3. Backfills every `Step`'s `StepTechStep` sequence against the new
+ * corpus ({@link backfillTechSteps}).
+ * 4. Marks the given suggestion ids `applied`/`rejected`, so
+ * `list-pending-training-suggestions.ts`'s next report doesn't
+ * surface them again.
+ *
+ * Usage:
+ *
+ * pnpm --filter api exec tsx src/scripts/retrain-tech-steps.ts --applied=12,13 --rejected=14
+ *
+ * `--applied`/`--rejected` are both optional — omitting both still runs
+ * the gate + backfill, just leaves every suggestion's `status` untouched
+ * (useful for re-running the backfill alone after a corpus edit made with
+ * no suggestions involved at all).
+ */
+async function retrainTechSteps(): Promise {
+ const appliedIds = parseSuggestionIds("applied");
+ const rejectedIds = parseSuggestionIds("rejected");
+
+ console.info("Evaluating the current classifier against the labeled evaluation set...");
+ const { overall } = await runTechStepEvalSuite();
+ console.info(
+ `F1 ${overall.f1.toFixed(3)} (precision ${overall.precision.toFixed(3)}, recall ${overall.recall.toFixed(3)})`,
+ );
+ if (overall.f1 < MIN_OVERALL_F1) {
+ throw new Error(
+ `Aggregate F1 ${overall.f1.toFixed(3)} is below the ${MIN_OVERALL_F1} regression floor — refusing to backfill. Revert or fix the corpus change and re-run.`,
+ );
+ }
+
+ const { total, changed } = await backfillTechSteps();
+ console.info(`Backfilled ${changed}/${total} step(s).`);
+
+ if (appliedIds.length > 0) {
+ // `updateMany`'s own `count` (rows actually matched/updated), not
+ // `appliedIds.length` (what was merely *asked for*) — an id that
+ // doesn't exist (typo, already-processed id) would otherwise log a
+ // success count that silently doesn't match what really changed.
+ const { count } = await prisma.techStepTrainingSuggestion.updateMany({
+ where: { id: { in: appliedIds } },
+ data: { status: "applied" },
+ });
+ console.info(`Marked ${count}/${appliedIds.length} suggestion(s) as applied.`);
+ }
+ if (rejectedIds.length > 0) {
+ const { count } = await prisma.techStepTrainingSuggestion.updateMany({
+ where: { id: { in: rejectedIds } },
+ data: { status: "rejected" },
+ });
+ console.info(`Marked ${count}/${rejectedIds.length} suggestion(s) as rejected.`);
+ }
+}
+
+retrainTechSteps()
+ .then(() => prisma.$disconnect())
+ .catch(async (err) => {
+ console.error(err);
+ await prisma.$disconnect();
+ process.exit(1);
+ });
diff --git a/apps/api/test/internal/tech-step-worker.routes.test.ts b/apps/api/test/internal/tech-step-worker.routes.test.ts
new file mode 100644
index 0000000..b969270
--- /dev/null
+++ b/apps/api/test/internal/tech-step-worker.routes.test.ts
@@ -0,0 +1,306 @@
+import { ErrorCode } from "@batch-cooking/shared";
+import { expect } from "chai";
+import request from "supertest";
+import { createApp } from "../../src/app.js";
+import { env } from "../../src/config/env.js";
+import { prisma } from "../../src/db/prisma.js";
+import { resetDatabase } from "../../test-support/reset-db.js";
+
+const SECRET_HEADER = "X-Internal-Worker-Secret";
+
+/** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — mirrors `recipe.test.ts`'s own `techStepId` helper. */
+async function techStepId(key: string): Promise {
+ const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } });
+ return techStep.id;
+}
+
+/** A minimal author + recipe + step fixture — these routes have no notion of a session/viewer, so nothing here needs to go through `/auth/signup` the way `recipe.test.ts`'s fixtures do. */
+async function createRecipeWithStep(
+ description = "Faire mijoter la sauce.",
+): Promise<{ stepId: number; recipeId: number }> {
+ const author = await prisma.userProfile.create({
+ data: {
+ firstName: "Test",
+ lastName: "Author",
+ email: `${crypto.randomUUID()}@example.test`,
+ passwordHash: "not-a-real-hash",
+ },
+ });
+ const recipe = await prisma.recipe.create({
+ data: {
+ name: "Recette",
+ authorId: author.id,
+ portions: 4,
+ steps: { create: [{ description, order: 0 }] },
+ },
+ include: { steps: true },
+ });
+ const step = recipe.steps[0];
+ if (!step) throw new Error("expected the fixture recipe to have one step");
+ return { stepId: step.id, recipeId: recipe.id };
+}
+
+describe("Internal tech-step worker routes", () => {
+ const app = createApp();
+
+ beforeEach(async () => {
+ await resetDatabase();
+ });
+
+ after(async () => {
+ await prisma.$disconnect();
+ });
+
+ describe("requireInternalWorker", () => {
+ it("rejects a request with no secret header with 401 NOT_AUTHENTICATED", async () => {
+ const res = await request(app).get("/internal/tech-steps/audit-batch");
+
+ expect(res.status).to.equal(401);
+ expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
+ });
+
+ it("rejects a request with the wrong secret with 401 NOT_AUTHENTICATED", async () => {
+ const res = await request(app)
+ .get("/internal/tech-steps/audit-batch")
+ .set(SECRET_HEADER, "definitely-not-the-right-secret");
+
+ expect(res.status).to.equal(401);
+ expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
+ });
+
+ it("rejects even the correct secret with 401 NOT_AUTHENTICATED on a plain user-facing route (no bypass of requireAuth)", async () => {
+ const res = await request(app)
+ .get("/recipes")
+ .query({ tab: "publique" })
+ .set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET ?? "irrelevant-unset-in-this-env");
+
+ expect(res.status).to.equal(401);
+ expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
+ });
+ });
+
+ // Every test below needs a real configured secret to exercise the success
+ // path — skipped (not failed) in an environment that hasn't set one, same
+ // "optional, but the surface fails closed without it" posture
+ // `INTERNAL_WORKER_SECRET` itself has (see config/env.ts). Both this
+ // repo's `.env.test.example` and `.github/workflows/ci.yml` set one, so
+ // this only actually skips in an environment that deliberately diverges
+ // from both.
+ describe("with a configured secret", () => {
+ before(function skipWithoutConfiguredSecret() {
+ if (env.INTERNAL_WORKER_SECRET === undefined) {
+ // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed without @types/mocha (not a dependency here) — same untyped-`this` shape a plain JS mocha callback would have.
+ (this as any).skip();
+ }
+ });
+
+ function withSecret(req: request.Test): request.Test {
+ return req.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string);
+ }
+
+ describe("GET /internal/tech-steps/audit-batch", () => {
+ // A true low-confidence positive case can't be asserted here without
+ // a live trained classifier to verify the exact sentence against
+ // first — same limitation `tech-step-eval-dataset.ts` documents for
+ // the same reason (no local Postgres was reachable in the session
+ // that introduced this file). This test instead covers the
+ // deterministic negative: a step the classifier confidently resolves
+ // (proven by `tech-step-matcher.test.ts`'s own identical-sentence
+ // case) must produce zero audit entries — nothing here should ever
+ // flag a confident match as worth a second opinion.
+ it("finds nothing to audit in a step the classifier confidently resolves", async () => {
+ await createRecipeWithStep("Faire mijoter à feu doux");
+
+ const res = await withSecret(
+ request(app).get("/internal/tech-steps/audit-batch").query({ locale: "fr" }),
+ );
+
+ expect(res.status).to.equal(200);
+ expect(res.body).to.deep.equal([]);
+ });
+
+ it("finds nothing to audit in a step naming no technique at all", async () => {
+ await createRecipeWithStep("Ranger les couverts dans le tiroir");
+
+ const res = await withSecret(
+ request(app).get("/internal/tech-steps/audit-batch").query({ locale: "fr" }),
+ );
+
+ expect(res.status).to.equal(200);
+ expect(res.body).to.deep.equal([]);
+ });
+
+ it("rejects a non-positive limit with 400 VALIDATION_ERROR", async () => {
+ const res = await withSecret(
+ request(app).get("/internal/tech-steps/audit-batch").query({ limit: 0 }),
+ );
+
+ expect(res.status).to.equal(400);
+ expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
+ });
+ });
+
+ describe("GET /internal/tech-steps/pending-corrections", () => {
+ it("returns unconsumed corrections, oldest first, excluding already-consumed ones", async () => {
+ const { stepId, recipeId } = await createRecipeWithStep();
+ const simmerId = await techStepId("simmer");
+ const author = await prisma.recipe
+ .findUniqueOrThrow({ where: { id: recipeId } })
+ .then((recipe) => recipe.authorId);
+
+ const older = await prisma.stepTechStepCorrection.create({
+ data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
+ });
+ const consumed = await prisma.stepTechStepCorrection.create({
+ data: {
+ stepId,
+ correctorId: author,
+ start: 0,
+ end: 5,
+ correctedTechStepId: simmerId,
+ consumedAt: new Date(),
+ },
+ });
+ const newer = await prisma.stepTechStepCorrection.create({
+ data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
+ });
+
+ const res = await withSecret(request(app).get("/internal/tech-steps/pending-corrections"));
+
+ expect(res.status).to.equal(200);
+ const ids = (res.body as Array<{ id: number }>).map((entry) => entry.id);
+ expect(ids).to.deep.equal([older.id, newer.id]);
+ expect(ids).to.not.include(consumed.id);
+ });
+
+ it("respects ?limit=", async () => {
+ const { stepId, recipeId } = await createRecipeWithStep();
+ const simmerId = await techStepId("simmer");
+ const author = await prisma.recipe
+ .findUniqueOrThrow({ where: { id: recipeId } })
+ .then((recipe) => recipe.authorId);
+ await prisma.stepTechStepCorrection.createMany({
+ data: [
+ { stepId, correctorId: author, start: 0, end: 5, correctedTechStepId: simmerId },
+ { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
+ ],
+ });
+
+ const res = await withSecret(
+ request(app).get("/internal/tech-steps/pending-corrections").query({ limit: 1 }),
+ );
+
+ expect(res.status).to.equal(200);
+ expect(res.body).to.have.length(1);
+ });
+ });
+
+ describe("POST /internal/tech-steps/training-suggestions", () => {
+ it("creates a suggestion and marks its source correction consumed", async () => {
+ const { stepId, recipeId } = await createRecipeWithStep();
+ const simmerId = await techStepId("simmer");
+ const author = await prisma.recipe
+ .findUniqueOrThrow({ where: { id: recipeId } })
+ .then((recipe) => recipe.authorId);
+ const correction = await prisma.stepTechStepCorrection.create({
+ data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
+ });
+
+ const res = await withSecret(
+ request(app)
+ .post("/internal/tech-steps/training-suggestions")
+ .send({
+ suggestions: [
+ {
+ techStepKey: "simmer",
+ locale: "fr",
+ suggestedSynonyms: ["frémissonner"],
+ suggestedUtterances: ["laisser frémissonner à feu très doux"],
+ sourceType: "correction",
+ sourceCorrectionId: correction.id,
+ },
+ ],
+ }),
+ );
+
+ expect(res.status).to.equal(201);
+ expect(res.body).to.deep.equal({ created: 1 });
+
+ const suggestions = await prisma.techStepTrainingSuggestion.findMany({
+ where: { techStepId: simmerId },
+ });
+ expect(suggestions).to.have.length(1);
+ expect(suggestions[0]?.sourceCorrectionId).to.equal(correction.id);
+ expect(suggestions[0]?.status).to.equal("pending");
+
+ const updatedCorrection = await prisma.stepTechStepCorrection.findUniqueOrThrow({
+ where: { id: correction.id },
+ });
+ expect(updatedCorrection.consumedAt).to.not.equal(null);
+ });
+
+ it("accepts an llm_audit suggestion with no sourceCorrectionId", async () => {
+ const res = await withSecret(
+ request(app)
+ .post("/internal/tech-steps/training-suggestions")
+ .send({
+ suggestions: [
+ {
+ techStepKey: "boil",
+ locale: "fr",
+ suggestedSynonyms: ["bouillonner"],
+ suggestedUtterances: [],
+ sourceType: "llm_audit",
+ },
+ ],
+ }),
+ );
+
+ expect(res.status).to.equal(201);
+ expect(res.body).to.deep.equal({ created: 1 });
+ });
+
+ it("rejects sourceType 'correction' with no sourceCorrectionId with 400 VALIDATION_ERROR", async () => {
+ const res = await withSecret(
+ request(app)
+ .post("/internal/tech-steps/training-suggestions")
+ .send({
+ suggestions: [
+ {
+ techStepKey: "boil",
+ locale: "fr",
+ suggestedSynonyms: ["bouillonner"],
+ suggestedUtterances: [],
+ sourceType: "correction",
+ },
+ ],
+ }),
+ );
+
+ expect(res.status).to.equal(400);
+ expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
+ });
+
+ it("rejects an unknown techStepKey with 404 TECH_STEP_NOT_FOUND", async () => {
+ const res = await withSecret(
+ request(app)
+ .post("/internal/tech-steps/training-suggestions")
+ .send({
+ suggestions: [
+ {
+ techStepKey: "not-a-real-tech-step",
+ locale: "fr",
+ suggestedSynonyms: [],
+ suggestedUtterances: [],
+ sourceType: "llm_audit",
+ },
+ ],
+ }),
+ );
+
+ expect(res.status).to.equal(404);
+ expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND);
+ });
+ });
+ });
+});
diff --git a/apps/api/test/recipe-matching/tech-step-eval.test.ts b/apps/api/test/recipe-matching/tech-step-eval.test.ts
new file mode 100644
index 0000000..1d60c67
--- /dev/null
+++ b/apps/api/test/recipe-matching/tech-step-eval.test.ts
@@ -0,0 +1,37 @@
+import { expect } from "chai";
+import { prisma } from "../../src/db/prisma.js";
+import {
+ MIN_OVERALL_F1,
+ runTechStepEvalSuite,
+} from "../../src/lib/recipe-matching/tech-step-eval-runner.js";
+import { resetDatabase } from "../../test-support/reset-db.js";
+
+/**
+ * Regression gate for `TECH_STEP_TRAINING_DATA` — every change to that
+ * corpus (including a maintainer applying suggestions from
+ * `TechStepTrainingSuggestion`, see `scripts/retrain-tech-steps.ts`) must
+ * keep this suite green. Runs {@link runTechStepEvalSuite} (the real
+ * trained classifier against `tech-step-eval-dataset.ts`) and asserts the
+ * aggregate F1 doesn't fall below {@link MIN_OVERALL_F1} — see that
+ * constant's own doc comment (`tech-step-eval-runner.ts`) for the real run
+ * it was calibrated against.
+ */
+
+describe("tech-step-eval", () => {
+ beforeEach(async () => {
+ await resetDatabase();
+ });
+
+ after(async () => {
+ await prisma.$disconnect();
+ });
+
+ it(`scores at least ${MIN_OVERALL_F1} aggregate F1 against the labeled evaluation set`, async () => {
+ const { overall, byKey } = await runTechStepEvalSuite();
+
+ expect(
+ overall.f1,
+ `aggregate F1 ${overall.f1.toFixed(3)} (precision ${overall.precision.toFixed(3)}, recall ${overall.recall.toFixed(3)}) fell below the ${MIN_OVERALL_F1} floor — per-technique breakdown: ${JSON.stringify(byKey)}`,
+ ).to.be.at.least(MIN_OVERALL_F1);
+ });
+});
diff --git a/apps/api/test/recipe/recipe-tech-step-correction.test.ts b/apps/api/test/recipe/recipe-tech-step-correction.test.ts
new file mode 100644
index 0000000..240c218
--- /dev/null
+++ b/apps/api/test/recipe/recipe-tech-step-correction.test.ts
@@ -0,0 +1,280 @@
+import type { SignupInput } from "@batch-cooking/shared";
+import { ErrorCode } from "@batch-cooking/shared";
+import { faker } from "@faker-js/faker";
+import { expect } from "chai";
+import request from "supertest";
+import { createApp } from "../../src/app.js";
+import { prisma } from "../../src/db/prisma.js";
+import { resetDatabase } from "../../test-support/reset-db.js";
+
+/** See `auth.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
+function buildSignupPayload(): SignupInput {
+ const firstName = faker.person.firstName();
+ const lastName = faker.person.lastName();
+ return {
+ firstName,
+ lastName,
+ email: faker.internet.email({ firstName, lastName }).toLowerCase(),
+ password: faker.internet.password({ length: 16 }),
+ };
+}
+
+/** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — mirrors `recipe.test.ts`'s own `techStepId` helper. */
+async function techStepId(key: string): Promise {
+ const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } });
+ return techStep.id;
+}
+
+describe("Recipe tech-step corrections", () => {
+ const app = createApp();
+
+ async function signup(): Promise<{ agent: ReturnType; profileId: number }> {
+ const agent = request.agent(app);
+ const res = await agent.post("/auth/signup").send(buildSignupPayload());
+ return { agent, profileId: res.body.id };
+ }
+
+ /** A `PUBLIC` recipe with one step — every viewer can see this, so most tests below don't need to juggle visibility on top of the correction logic itself. */
+ async function createPublicRecipeWithStep(
+ authorId: number,
+ description = "Faire mijoter la sauce.",
+ ): Promise<{ recipeId: number; stepId: number }> {
+ const recipe = await prisma.recipe.create({
+ data: {
+ name: "Recette",
+ authorId,
+ visibility: "PUBLIC",
+ portions: 4,
+ steps: { create: [{ description, order: 0 }] },
+ },
+ include: { steps: true },
+ });
+ const step = recipe.steps[0];
+ if (!step) throw new Error("expected the fixture recipe to have one step");
+ return { recipeId: recipe.id, stepId: step.id };
+ }
+
+ beforeEach(async () => {
+ await resetDatabase();
+ });
+
+ after(async () => {
+ await prisma.$disconnect();
+ });
+
+ describe("POST /recipes/:id/steps/:stepId/corrections", () => {
+ it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
+ const { profileId } = await signup();
+ const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
+
+ const res = await request(app)
+ .post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
+ .send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
+
+ expect(res.status).to.equal(401);
+ expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
+ });
+
+ 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();
+ // "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 simmerId = await techStepId("simmer");
+
+ const res = await agent
+ .post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
+ .send({ start: 6, end: 13, correctedTechStepId: simmerId });
+
+ expect(res.status).to.equal(201);
+ expect(res.body.correction.previousTechStep).to.equal(null);
+ expect(res.body.correction.correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
+ expect(res.body.correction.start).to.equal(6);
+ 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), updating the existing techSteps entry in place", async () => {
+ const { agent, profileId } = await signup();
+ const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
+ const simmerId = await techStepId("simmer");
+ 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
+ .post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
+ .send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
+
+ expect(res.status).to.equal(201);
+ expect(res.body.correction.previousTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
+ 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 () => {
+ const { profileId: authorId } = await signup();
+ const { agent: otherAgent } = await signup();
+ const { recipeId, stepId } = await createPublicRecipeWithStep(authorId);
+
+ const res = await otherAgent
+ .post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
+ .send({ start: 6, end: 13, correctedTechStepId: await techStepId("simmer") });
+
+ expect(res.status).to.equal(201);
+ });
+
+ it("rejects both previousTechStepId and correctedTechStepId absent 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: 0, end: 5 });
+
+ expect(res.status).to.equal(400);
+ expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
+ });
+
+ it("rejects end <= start 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: 5, end: 5, correctedTechStepId: await techStepId("simmer") });
+
+ expect(res.status).to.equal(400);
+ expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
+ });
+
+ it("rejects a 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 + 10,
+ correctedTechStepId: await techStepId("simmer"),
+ });
+
+ expect(res.status).to.equal(400);
+ expect(res.body.code).to.equal(ErrorCode.INVALID_CORRECTION_SPAN);
+ });
+
+ it("rejects an unknown correctedTechStepId with 404 TECH_STEP_NOT_FOUND", async () => {
+ const { agent, profileId } = await signup();
+ const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
+
+ const res = await agent
+ .post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
+ .send({ start: 0, end: 5, correctedTechStepId: 999_999 });
+
+ expect(res.status).to.equal(404);
+ expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND);
+ });
+
+ it("rejects a step that exists but isn't visible to the viewer with 404 RECIPE_NOT_FOUND", async () => {
+ const { profileId: authorId } = await signup();
+ const { agent: otherAgent } = await signup();
+ const recipe = await prisma.recipe.create({
+ data: {
+ name: "Secrète",
+ authorId,
+ portions: 4,
+ steps: { create: [{ description: "Faire mijoter la sauce.", order: 0 }] },
+ },
+ include: { steps: true },
+ });
+ const step = recipe.steps[0];
+ if (!step) throw new Error("expected the fixture recipe to have one step");
+
+ const res = await otherAgent
+ .post(`/recipes/${recipe.id}/steps/${step.id}/corrections`)
+ .send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
+
+ expect(res.status).to.equal(404);
+ expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
+ });
+
+ it("rejects a stepId that belongs to a different recipe than the URL's :id with 404 STEP_NOT_FOUND", async () => {
+ const { agent, profileId } = await signup();
+ const { recipeId: otherRecipeId } = await createPublicRecipeWithStep(profileId);
+ const { stepId } = await createPublicRecipeWithStep(profileId);
+
+ const res = await agent
+ .post(`/recipes/${otherRecipeId}/steps/${stepId}/corrections`)
+ .send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
+
+ expect(res.status).to.equal(404);
+ expect(res.body.code).to.equal(ErrorCode.STEP_NOT_FOUND);
+ });
+ });
+
+ describe("GET /recipes/:id/steps/:stepId/corrections", () => {
+ it("returns every correction submitted for the step, most recent first", 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 });
+ await agent
+ .post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
+ .send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
+
+ const res = await agent.get(`/recipes/${recipeId}/steps/${stepId}/corrections`);
+
+ expect(res.status).to.equal(200);
+ expect(res.body).to.have.length(2);
+ expect(res.body[0].correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
+ expect(res.body[1].correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
+ });
+
+ it("returns an empty list when nothing has been submitted yet", async () => {
+ const { agent, profileId } = await signup();
+ const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
+
+ const res = await agent.get(`/recipes/${recipeId}/steps/${stepId}/corrections`);
+
+ expect(res.status).to.equal(200);
+ expect(res.body).to.deep.equal([]);
+ });
+ });
+});
diff --git a/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx b/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx
new file mode 100644
index 0000000..effbb22
--- /dev/null
+++ b/apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx
@@ -0,0 +1,121 @@
+import "../../src/i18n/i18n";
+import { TechStepCorrectionPopover } from "../../src/features/recipes/steps/TechStepCorrectionPopover";
+
+// Mounts the popover in isolation (no StepDescription/selection plumbing
+// around it) — same "generic component test" posture as CheckboxOption.cy.tsx,
+// but this one needs `../../src/i18n/i18n` imported for its side effect
+// (initializes the default i18next instance `useTranslation` falls back to
+// with no `` in the tree — see that module's own doc
+// comment) since, unlike Checkbox/Radio, this component calls
+// `useTranslation()`.
+
+const cook = { id: 1, key: "cook" };
+const simmer = { id: 3, key: "simmer" };
+
+function mountPopover(
+ overrides: Partial<{
+ previousTechStepId: number | null;
+ onClose: () => void;
+ onSubmitted: (correction: unknown) => void;
+ }> = {},
+) {
+ cy.mount(
+
+ {/* A genuinely separate sibling to click for the "outside click closes it" test — clicking blindly at a viewport coordinate would risk still landing inside the popover, which fills most of the mounted area on its own. */}
+
+ {})}
+ onSubmitted={overrides.onSubmitted ?? (() => {})}
+ />
+
,
+ );
+}
+
+describe("TechStepCorrectionPopover", () => {
+ beforeEach(() => {
+ cy.intercept("GET", "**/reference/tech-steps", { statusCode: 200, body: [cook, simmer] }).as(
+ "getTechSteps",
+ );
+ });
+
+ it("shows the selected text and every technique option once loaded", () => {
+ mountPopover();
+ cy.wait("@getTechSteps");
+
+ cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
+ cy.get(".tech-step-correction-popover__list button").should("have.length", 2);
+ });
+
+ it("offers a 'no technique here' option only when correcting an existing match", () => {
+ mountPopover({ previousTechStepId: null });
+ cy.wait("@getTechSteps");
+ cy.get(".tech-step-correction-popover__remove").should("not.exist");
+
+ mountPopover({ previousTechStepId: cook.id });
+ cy.wait("@getTechSteps");
+ cy.get(".tech-step-correction-popover__remove").should("exist");
+ });
+
+ it("submits the selected technique and calls onSubmitted", () => {
+ // Asserting on the resolved `@submitCorrection` interception below,
+ // rather than inside this handler — a Chai assertion failing *inside*
+ // a `cy.intercept` callback surfaces as an opaque "onResponse cannot be
+ // called twice" Cypress internal error instead of a normal assertion
+ // failure, found while writing this exact test.
+ cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
+ statusCode: 201,
+ body: {
+ id: 1,
+ start: 0,
+ end: 5,
+ previousTechStep: null,
+ correctedTechStep: simmer,
+ createdAt: new Date().toISOString(),
+ },
+ }).as("submitCorrection");
+ const onSubmitted = cy.stub().as("onSubmitted");
+ mountPopover({ onSubmitted });
+ cy.wait("@getTechSteps");
+
+ cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
+
+ cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
+ start: 0,
+ end: 5,
+ previousTechStepId: null,
+ correctedTechStepId: simmer.id,
+ });
+ cy.get("@onSubmitted").should("have.been.calledOnce");
+ });
+
+ it("shows an error message and stays open when the submission fails", () => {
+ cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
+ statusCode: 404,
+ body: { code: 4051, message: "TechStep not found" },
+ }).as("submitCorrection");
+ const onClose = cy.stub().as("onClose");
+ mountPopover({ onClose });
+ cy.wait("@getTechSteps");
+
+ cy.contains(".tech-step-correction-popover__list button", "Cuire").click();
+
+ cy.wait("@submitCorrection");
+ cy.get(".field-error").should("be.visible");
+ cy.get("@onClose").should("not.have.been.called");
+ });
+
+ it("calls onClose on an outside click", () => {
+ const onClose = cy.stub().as("onClose");
+ mountPopover({ onClose });
+ cy.wait("@getTechSteps");
+
+ cy.get('[data-testid="outside-popover"]').click();
+
+ cy.get("@onClose").should("have.been.calledOnce");
+ });
+});
diff --git a/apps/web/cypress/component/highlight-tech-steps.cy.tsx b/apps/web/cypress/component/highlight-tech-steps.cy.tsx
index a581538..f06a6c4 100644
--- a/apps/web/cypress/component/highlight-tech-steps.cy.tsx
+++ b/apps/web/cypress/component/highlight-tech-steps.cy.tsx
@@ -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
// 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(
key: string,
id: number,
start: number,
end: number,
context?: { start: number; end: number },
+ source: StepTechStepView["source"] = "auto",
): StepTechStepView {
return {
techStep: { id, key },
start,
end,
+ source,
...(context ? { contextStart: context.start, contextEnd: context.end } : {}),
};
}
@@ -25,7 +27,7 @@ function techStep(
describe("splitDescriptionByTechSteps", () => {
it("returns the whole description as one plain segment when there are no matches", () => {
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),
]);
expect(result).to.deep.equal([
- { text: "Faire ", techStep: null, isKeyword: false },
- { text: "mijoter", techStep: { id: 1, key: "simmer" }, isKeyword: true },
- { text: " à feu doux", techStep: null, isKeyword: false },
+ { text: "Faire ", techStep: null, isKeyword: false, source: null },
+ { text: "mijoter", techStep: { id: 1, key: "simmer" }, isKeyword: true, source: "auto" },
+ { text: " à feu doux", techStep: null, isKeyword: false, source: null },
]);
});
it("handles a match at the very start, with nothing before it", () => {
const result = splitDescriptionByTechSteps("Hacher les oignons", [techStep("chop", 2, 0, 6)]);
expect(result).to.deep.equal([
- { text: "Hacher", techStep: { id: 2, key: "chop" }, isKeyword: true },
- { text: " les oignons", techStep: null, isKeyword: false },
+ { text: "Hacher", techStep: { id: 2, key: "chop" }, isKeyword: true, source: "auto" },
+ { text: " les oignons", techStep: null, isKeyword: false, source: null },
]);
});
it("handles a match at the very end, with nothing after it", () => {
const result = splitDescriptionByTechSteps("Faire cuire", [techStep("cook", 3, 6, 11)]);
expect(result).to.deep.equal([
- { text: "Faire ", techStep: null, isKeyword: false },
- { text: "cuire", techStep: { id: 3, key: "cook" }, isKeyword: true },
+ { text: "Faire ", techStep: null, isKeyword: false, source: null },
+ { text: "cuire", techStep: { id: 3, key: "cook" }, isKeyword: true, source: "auto" },
]);
});
@@ -71,6 +73,7 @@ describe("splitDescriptionByTechSteps", () => {
text: "Préchauffer",
techStep: { id: 4, key: "preheat" },
isKeyword: true,
+ source: "auto",
});
});
@@ -88,17 +91,23 @@ describe("splitDescriptionByTechSteps", () => {
it("drops a match whose end is past the end of the description", () => {
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", () => {
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", () => {
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", () => {
@@ -110,7 +119,7 @@ describe("splitDescriptionByTechSteps", () => {
techStep("cook", 2, 0, 5),
]);
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,42 @@ describe("splitDescriptionByTechSteps", () => {
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" },
+ ]);
+ });
+
+ it("never lets one match's wider context swallow another match's own keyword span", () => {
+ // The motivating real bug (found via live testing, not invented for
+ // this test): "simmer" is the only NER candidate `splitIntoClauses`
+ // found, so its context spans the *entire* description — before this
+ // was fixed, that wide context advanced `cursor` past 39, silently
+ // dropping "setAside"'s own keyword span (a manual correction on
+ // "materiel", a word with no relation to "simmer" at all) instead of
+ // rendering it.
+ const text = "Faire mijoter la sauce, puis ranger le materiel.";
+ const result = splitDescriptionByTechSteps(text, [
+ techStep("simmer", 1, 6, 13, { start: 0, end: 48 }),
+ techStep("setAside", 2, 39, 47, undefined, "manual"),
+ ]);
+ const keywordSegments = result.filter((s) => s.isKeyword);
+ expect(
+ keywordSegments.map((s) => ({ key: s.techStep?.key, text: s.text, source: s.source })),
+ ).to.deep.equal([
+ { key: "simmer", text: "mijoter", source: "auto" },
+ { key: "setAside", text: "materiel", source: "manual" },
+ ]);
+ expect(result.map((s) => s.text).join("")).to.equal(text);
+ });
+
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", () => {
// The motivating example: "Dans une poêle chaude, faire chauffer une
@@ -128,12 +173,23 @@ describe("splitDescriptionByTechSteps", () => {
techStep("preheat", 4, 9, 21, { start: 0, end: 21 }),
]);
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",
techStep: null,
isKeyword: false,
+ source: null,
},
]);
});
@@ -143,8 +199,13 @@ describe("splitDescriptionByTechSteps", () => {
techStep("preheat", 4, 0, 11, { start: 0, end: 19 }),
]);
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 +214,19 @@ describe("splitDescriptionByTechSteps", () => {
techStep("preheat", 4, 17, 28, { start: 7, end: 28 }),
]);
expect(result).to.deep.equal([
- { text: "mettre ", techStep: null, isKeyword: false },
- { text: "le four à ", techStep: { id: 4, key: "preheat" }, isKeyword: false },
- { text: "préchauffer", techStep: { id: 4, key: "preheat" }, isKeyword: true },
+ { text: "mettre ", techStep: null, isKeyword: false, source: null },
+ {
+ 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 +242,9 @@ describe("splitDescriptionByTechSteps", () => {
// contextEnd (5) is before the keyword's own end (13) — malformed.
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 },
+ ]);
});
});
});
diff --git a/apps/web/cypress/e2e/recipes.feature b/apps/web/cypress/e2e/recipes.feature
index bc0b5d1..29914e2 100644
--- a/apps/web/cypress/e2e/recipes.feature
+++ b/apps/web/cypress/e2e/recipes.feature
@@ -27,6 +27,24 @@ Feature: Managing a recipe from the catalog
When I focus the highlighted technique "Cuire"
Then the tooltip should show "Cuire"
+ Scenario: Corrects a detected technique from its highlight, visible immediately as a manual match
+ Given the recipe catalog contains "Omelette"
+ And recipe 2's detail is available
+ And the tech steps reference list has options
+ And correcting step 2's "Cuire" match will succeed
+ When I visit "/recettes/2"
+ And I click the highlighted technique "Cuire"
+ Then I should see the technique correction options
+ When I choose "Mijoter" as the correct technique
+ Then the correction request should have been made
+ # 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
Given the recipe catalog contains "Omelette"
And recipe 2's detail is available
diff --git a/apps/web/cypress/e2e/recipes.ts b/apps/web/cypress/e2e/recipes.ts
index 14a75f5..e534164 100644
--- a/apps/web/cypress/e2e/recipes.ts
+++ b/apps/web/cypress/e2e/recipes.ts
@@ -43,7 +43,7 @@ const omeletteDetail = {
// "Cuire" -> the `cook` technique, matching real reference-seed-data.ts
// (`\bcui(re|sez|sant|sson)\b`) — "poêle" itself matches nothing
// (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" }],
},
],
};
@@ -68,6 +68,62 @@ Given("deleting recipe 2 will succeed", () => {
cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe");
});
+// Step 2 is `omeletteDetail`'s "Cuire à la poêle." step, whose only
+// existing match is `cook` (id 1) — see that fixture above. The response
+// mirrors `SubmitTechStepCorrectionResult` (packages/shared): the audit
+// record (reassigning the match to `simmer`, id 3, "Mijoter" — see `the
+// 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', () => {
+ cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
+ statusCode: 201,
+ body: {
+ correction: {
+ id: 1,
+ start: 0,
+ end: 5,
+ previousTechStep: { id: 1, key: "cook" },
+ correctedTechStep: { id: 3, key: "simmer" },
+ createdAt: new Date().toISOString(),
+ },
+ techSteps: [{ techStep: { id: 3, key: "simmer" }, start: 0, end: 5, source: "manual" }],
+ },
+ }).as("correction");
+});
+
+When("I click the highlighted technique {string}", (text: string) => {
+ cy.contains(".step-tech-step", text).click();
+});
+
+Then("I should see the technique correction options", () => {
+ cy.get(".tech-step-correction-popover").should("be.visible");
+});
+
+When("I choose {string} as the correct technique", (label: string) => {
+ 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", () => {
+ // Asserts the actual span, not just that *a* request fired — a real bug
+ // (StepDescription.tsx's click handler reading a shared, still-mutating
+ // `offset` variable by reference instead of a value captured at render
+ // time) once sent `end` all the way to the end of the description
+ // instead of "Cuire"'s own tight [0, 5) span, and a request-fired-only
+ // assertion here didn't catch it — found only via manual testing.
+ cy.wait("@correction")
+ .its("request.body")
+ .should("deep.include", { start: 0, end: 5, previousTechStepId: 1 });
+});
+
Then("the recipe {string} should not be visible in the table", (name: string) => {
cy.contains(".recipe-table__name", name).should("not.exist");
});
diff --git a/apps/web/cypress/support/step_definitions/reference-data.steps.ts b/apps/web/cypress/support/step_definitions/reference-data.steps.ts
index f105815..a457b4a 100644
--- a/apps/web/cypress/support/step_definitions/reference-data.steps.ts
+++ b/apps/web/cypress/support/step_definitions/reference-data.steps.ts
@@ -40,6 +40,21 @@ Given("the sources reference list is empty", () => {
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
});
+// `id`/`key` pairs mirror `recipes.ts`'s `omeletteDetail` fixture (`cook`,
+// id 1, is the step's existing match) plus a second option
+// (`simmer`/"Mijoter") for `recipes.feature`'s correction scenario to
+// re-assign to — TechStepCorrectionPopover's own picker needs at least two
+// choices for that scenario to be a meaningful correction, not a no-op.
+Given("the tech steps reference list has options", () => {
+ cy.intercept("GET", "**/reference/tech-steps", {
+ statusCode: 200,
+ body: [
+ { id: 1, key: "cook" },
+ { id: 3, key: "simmer" },
+ ],
+ });
+});
+
// The real first entry (`theMealDb`) mirrors what's actually seeded
// (`reference-seed-data.ts`'s `registerAllRecipeSources`/
// `syncRecipeSources`); the second is illustrative only — a future
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index dcdca30..9cc6b9a 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -19,6 +19,10 @@ import {
type SafeUserProfile,
type SignupInput,
type SourceView,
+ type StepTechStepCorrectionView,
+ type SubmitTechStepCorrectionInput,
+ type SubmitTechStepCorrectionResult,
+ type TechStepView,
type ThemePreference,
type UnitView,
type UpdateRecipeInput,
@@ -179,6 +183,11 @@ export class ApiClient {
return this._request("/reference/units");
}
+ /** Reference list of detected cooking techniques — static, non-administrable (`TechStepCorrectionPopover`'s technique picker). Public — no session required. */
+ public getTechSteps(): Promise {
+ return this._request("/reference/tech-steps");
+ }
+
/** Reference list of implemented recipe sources (onboarding wizard's source step, `/parametres/foyer`) — empty until a concrete source is registered. Public — no session required. */
public getSources(): Promise {
return this._request("/reference/sources");
@@ -267,6 +276,26 @@ export class ApiClient {
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. The response's `techSteps` is the step's fresh, immediately up-to-date technique sequence — see `SubmitTechStepCorrectionResult`'s doc comment. */
+ public submitTechStepCorrection(
+ recipeId: number,
+ stepId: number,
+ input: SubmitTechStepCorrectionInput,
+ ): Promise {
+ return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`, {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+ }
+
+ /** Every correction submitted so far for `stepId`, most recent first. */
+ public getTechStepCorrections(
+ recipeId: number,
+ stepId: number,
+ ): Promise {
+ return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`);
+ }
+
/** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */
public getCurrentHouse(): Promise {
return this._request("/house/current");
diff --git a/apps/web/src/features/recipes/RecipeDetailPanel.tsx b/apps/web/src/features/recipes/RecipeDetailPanel.tsx
index 065fec7..47324e1 100644
--- a/apps/web/src/features/recipes/RecipeDetailPanel.tsx
+++ b/apps/web/src/features/recipes/RecipeDetailPanel.tsx
@@ -205,11 +205,34 @@ export function RecipeDetailPanel({
{t("recipes.stepsTitle")}
+ {/* 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 && (
+
{step.picture && }
-
+ {/* Tied to `showActions` (not unconditionally on): that flag
+ already distinguishes a full recipe view from a lightweight
+ preview (`RecipePickerDialog`'s browsing step, `showActions={false}`)
+ — offering technique corrections in a quick "pick a recipe
+ for planning" preview would be more distracting than
+ useful there, even though the API itself allows it for any
+ viewer who can see the recipe. */}
+
))}
diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss
index ed923a0..a9ed09d 100644
--- a/apps/web/src/features/recipes/recipes.scss
+++ b/apps/web/src/features/recipes/recipes.scss
@@ -627,12 +627,101 @@
}
}
+// 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
// was found in) used to be highlighted here too, more subtly — turned back
// off (see `StepDescription.tsx`'s doc comment): the backend still
// computes and persists `contextStart`/`contextEnd`, this file just no
// 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) ---------------
+
+// Deliberately *not* `position: absolute` (unlike `.calendar-popover`) — see
+// `TechStepCorrectionPopover.tsx`'s doc comment for why this renders inline
+// in the document flow right below the step's own description instead of
+// floating anchored at the selection.
+.tech-step-correction-popover {
+ margin-top: var(--space-xs);
+ padding: var(--space-md);
+ background: var(--color-surface-alt);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+
+ &__selection {
+ font-weight: 600;
+ margin: 0 0 var(--space-sm);
+ }
+
+ &__list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-xs);
+ list-style: none;
+ margin: 0 0 var(--space-sm);
+ padding: 0;
+
+ button {
+ padding: 0.3rem 0.6rem;
+ font-size: var(--font-size-sm);
+ color: var(--color-text);
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-pill);
+ cursor: pointer;
+
+ &:hover:not(:disabled) {
+ background: color-mix(in srgb, var(--color-primary) 14%, transparent);
+ border-color: var(--color-primary);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+
+ // Nested (rather than a sibling `&__remove` block) so its border-color
+ // wins over the plain `button` rule above by class-count specificity,
+ // no `!important` needed.
+ .tech-step-correction-popover__remove {
+ color: var(--color-error);
+ border-color: var(--color-error);
+ }
+ }
+
+ &__cancel {
+ background: none;
+ border: none;
+ color: var(--color-text-muted);
+ text-decoration: underline;
+ cursor: pointer;
+ padding: 0;
+ font-size: var(--font-size-sm);
+ }
+}
+
// --- Favorite star toggle (detail panel header) -----------------------------
.favorite-star-button {
position: absolute;
diff --git a/apps/web/src/features/recipes/steps/StepDescription.tsx b/apps/web/src/features/recipes/steps/StepDescription.tsx
index 7e5641e..042478f 100644
--- a/apps/web/src/features/recipes/steps/StepDescription.tsx
+++ b/apps/web/src/features/recipes/steps/StepDescription.tsx
@@ -1,8 +1,10 @@
-import type { StepTechStepView } from "@batch-cooking/shared";
-import { Fragment } from "react";
+import type { StepTechStepView, SubmitTechStepCorrectionResult } from "@batch-cooking/shared";
+import { Fragment, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "../../../components/ui/Tooltip";
import { splitDescriptionByTechSteps } from "./highlight-tech-steps";
+import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
+import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
/**
* A recipe step's description, with every detected technique's exact
@@ -23,48 +25,161 @@ import { splitDescriptionByTechSteps } from "./highlight-tech-steps";
*
* `techStep.key` resolves its tooltip label through `catalog.techSteps.`
* 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
+ * click an existing highlight to open a {@link TechStepCorrectionPopover} —
+ * see `use-text-selection.ts` for how a browser selection is translated
+ * into an absolute `[start, end)` span. When `!editable`, every segment
+ * renders exactly as before (no extra wrapping elements, no `data-offset`,
+ * no click handlers) — this mode is purely additive, not a rewrite of the
+ * 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({
description,
techSteps,
+ editable = false,
+ recipeId,
+ stepId,
}: {
description: string;
techSteps: StepTechStepView[];
+ /** Requires `recipeId`/`stepId` when `true` — omit (or leave `false`) for a read-only view with nothing real to correct against yet (e.g. `RecipeDetailPanel`'s `"loaded-draft"` unsaved-preview branch). */
+ editable?: boolean;
+ recipeId?: number;
+ stepId?: number;
}) {
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(null);
+ const { getSelectionRange } = useTextSelection(containerRef);
+
+ const [activeCorrection, setActiveCorrection] = useState<{
+ range: TextSelectionRange;
+ selectedText: string;
+ previousTechStepId: number | null;
+ } | null>(null);
+
+ function handleMouseUp() {
+ if (!editable) return;
+ const range = getSelectionRange();
+ if (!range) return;
+ setActiveCorrection({
+ range,
+ selectedText: description.slice(range.start, range.end),
+ previousTechStepId: null,
+ });
+ }
+
+ function handleSubmitted(result: SubmitTechStepCorrectionResult) {
+ setLiveTechSteps(result.techSteps);
+ }
+
+ // Tracks each segment's own absolute start offset into `description` as
+ // the map below walks them in order — segments are contiguous and cover
+ // the whole description (see `splitDescriptionByTechSteps`'s doc
+ // comment), so a running total is exact, no re-derivation needed.
+ let offset = 0;
return (
-
- {segments.map((segment, index) => {
- // A segment's own text/techStep don't uniquely identify it (the
- // same word can appear twice in one description) — index is the
- // only thing that does, but this list is fully regenerated from
- // `description`/`techSteps` on every render (never reordered or
- // spliced in place), so using it as part of the key is safe here.
- const key = `${index}-${segment.text}`;
- if (!segment.techStep) return {segment.text};
+ <>
+
+ {segments.map((segment, index) => {
+ const start = offset;
+ offset += segment.text.length;
+ // Captured now, not read as `offset` later inside a click
+ // handler below — `offset` keeps mutating for every subsequent
+ // segment this same `.map()` pass renders, so a closure
+ // referencing it directly would see its *final* value (the end
+ // of the whole description) whenever it actually fires, long
+ // after render — found via a real correction submitted with
+ // `end` far past this segment's own text.
+ const end = offset;
+ // A segment's own text/techStep don't uniquely identify it (the
+ // same word can appear twice in one description) — index is the
+ // only thing that does, but this list is fully regenerated from
+ // `description`/`liveTechSteps` on every render (never reordered
+ // or spliced in place), so using it as part of the key is safe
+ // here.
+ const key = `${index}-${segment.text}`;
- if (!segment.isKeyword) {
- // Context-only run — rendered as plain text, same as a segment
- // with no technique at all (see this component's doc comment for
- // why the wider-clause highlight was turned back off).
- return {segment.text};
- }
- return (
-
- {/* A real
- );
- })}
-
+ if (!segment.techStep || !segment.isKeyword) {
+ // Context-only or plain run — rendered as plain text in
+ // read-only mode, same as before this component supported
+ // `editable` at all (see this component's doc comment for why
+ // the wider-clause highlight itself was turned back off).
+ // Editable mode still wraps it in a `data-offset` span so a
+ // selection starting/ending in plain text resolves correctly.
+ if (!editable) return {segment.text};
+ return (
+
+ {segment.text}
+
+ );
+ }
+
+ 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 (
+
+ {/* A real , not a , so it's natively focusable
+ (keyboard/screen-reader users can reach the tooltip) without
+ fighting the "non-interactive element" a11y lint a bare
+ tabIndex on would trip — styled to read as inline
+ highlighted text, not as a button (see .step-tech-step). */}
+
+ setActiveCorrection({
+ range: { start, end },
+ selectedText: segment.text,
+ previousTechStepId: techStep.id,
+ })
+ : undefined
+ }
+ >
+ {segment.text}
+
+
+ );
+ })}
+
+ {editable && activeCorrection && recipeId !== undefined && stepId !== undefined && (
+ setActiveCorrection(null)}
+ onSubmitted={handleSubmitted}
+ />
+ )}
+ >
);
}
diff --git a/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx b/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx
new file mode 100644
index 0000000..57d9c09
--- /dev/null
+++ b/apps/web/src/features/recipes/steps/TechStepCorrectionPopover.tsx
@@ -0,0 +1,147 @@
+import {
+ ErrorCode,
+ type SubmitTechStepCorrectionResult,
+ type TechStepView,
+} from "@batch-cooking/shared";
+import { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { ApiError, apiClient } from "../../../api/client";
+import { errorMessageService } from "../../../services/error-message.service";
+import type { TextSelectionRange } from "./use-text-selection";
+
+/**
+ * Small non-modal popover letting a viewer assign a technique to a selected
+ * span of a step's description, or clear/relabel an existing match —
+ * opened by `StepDescription`'s editable mode. Same `mousedown`-outside-
+ * close pattern as `PlanningPage`'s `CalendarPopover`, not `Dialog.tsx`'s
+ * native `