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>
306 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|
|
});
|