batchCooking/apps/api/test/internal/tech-step-worker.routes.test.ts
Nicolas 53d415fddb feat(tech-steps): fiabilise la detection des tech steps (corpus + LLM + corrections utilisateur)
Une seule feature livree en une seule PR, en 5 phases :

- Phase 1 : enrichit le corpus NLP (tech-step-training-data.ts) et ajoute
  un harness d'evaluation (precision/rappel/F1) avec un jeu de test etiquete
  - la premiere metrique objective de qualite pour ce classifieur.
- Phase 2 : schema Prisma (StepTechStepCorrection, TechStepTrainingSuggestion)
  + endpoints utilisateur (POST/GET corrections, ouverts a tout viewer, pas
  seulement l'auteur) + endpoints internes /internal/tech-steps/* proteges
  par secret partage (requireInternalWorker).
- Phase 3 : UI de highlight/correction cote web (selection de texte ->
  association a une technique, ou clic sur un highlight existant pour le
  corriger/supprimer) - verifiee via Cypress (component + e2e, en Chrome
  reel).
- Phase 4 : worker LLM autonome (services/tech-step-llm-worker, hors du
  monorepo pnpm comme experiments/llm-tech-step-poc) qui audite les clauses
  a faible confiance et transforme les corrections utilisateur en
  suggestions d'entrainement, sans jamais toucher le chemin interactif.
- Phase 5 : script retrain-tech-steps.ts (gate de regression F1 + backfill)
  et list-pending-training-suggestions.ts pour la revue humaine avant
  application au corpus.

Verification effectuee cette session : tsc/biome sur l'ensemble du repo,
build complet (pnpm build), suite Cypress complete (component 39/39, e2e
75/76 - le seul echec est preexistant et sans rapport, cote
recipe-form.feature/ingredient-picker), tests unitaires du worker (6/6) et
son install/typecheck reels contre node-llama-cpp. Les tests Mocha
d'apps/api (Phases 1 et 2) n'ont pas pu etre executes dans cette session
(pas de Postgres local disponible) - a lancer avant merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 09:48:02 +02:00

306 lines
12 KiB
TypeScript

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<number> {
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);
});
});
});
});