PR 5 (derniere) du chantier admin. Remplace le duo CLI list-pending-training-suggestions.ts / retrain-tech-steps.ts par une UI. API (admin-tech-steps.service.ts, routes /admin/tech-steps/*, requireAdmin) : - GET /suggestions : TechStepTrainingSuggestion filtrees, groupees par technique, enrichies du contexte de la correction source. - GET /corrections : corrections brutes filtrables, incluant les suppressions correctedTechStepId:null invisibles ailleurs. - PATCH /suggestions/:id : edite synonymes/phrases et/ou status. - GET /training-data-snippet : bloc training_data.py a coller (lecture seule). - POST /retrain : runTechStepEvalSuite() (gate F1 vs MIN_OVERALL_F1) puis si passe backfillTechSteps() + marquage applied/rejected. Verrou memoire -> 409 RETRAIN_ALREADY_RUNNING. Gate echoue -> 200 gatePassed:false. N'edite pas le .py ni ne redemarre l'intent-service (manuel). Shared : nouveau ErrorCode RETRAIN_ALREADY_RUNNING (4023, + cle i18n apps/web), schemas (list*/update*/retrain*/snippet), types (TrainingSuggestion*/Correction*/RetrainResultView...). Front : CorrectionsPage (onglets Suggestions / Corrections brutes, bandeau caveat permanent, cartes editables + Appliquer/Rejeter, panneau snippet, panneau gate F1). Logique pure corrections.ts. i18n admin.corrections.*. AdminApiClient : 5 methodes. Tests : Mocha admin-tech-steps.test.ts (401 partout, groupement+filtre, PATCH 400/404/ok, corrections incluant removals, snippet, retrain shape + 409 concurrent) ; Cypress corrections.cy.ts (4 verts). Admin-web Cypress 13/13. specs/backend-architecture.md : section tri + retrain. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
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 { env } from "../src/config/env.js";
|
|
import { prisma } from "../src/db/prisma.js";
|
|
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
|
|
import { resetDatabase } from "../test-support/reset-db.js";
|
|
|
|
async function seedAdmin(): Promise<{ email: string; password: string }> {
|
|
const email = faker.internet.email().toLowerCase();
|
|
const password = faker.internet.password({ length: 16 });
|
|
await prisma.adminUser.create({
|
|
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
|
|
});
|
|
return { email, password };
|
|
}
|
|
|
|
async function techStepId(key: string): Promise<number> {
|
|
return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id;
|
|
}
|
|
|
|
/** A recipe + one step + one correction on it, optionally already turned into a suggestion. */
|
|
async function seedCorrectionAndSuggestion(options: {
|
|
clause: string;
|
|
correctedKey: string | null;
|
|
withSuggestion?: { status: string; synonyms: string[] };
|
|
}) {
|
|
const author = await prisma.userProfile.create({
|
|
data: {
|
|
firstName: "T",
|
|
lastName: "A",
|
|
email: `${faker.string.uuid()}@example.test`,
|
|
passwordHash: "x",
|
|
},
|
|
});
|
|
const recipe = await prisma.recipe.create({
|
|
data: {
|
|
name: "R",
|
|
authorId: author.id,
|
|
portions: 4,
|
|
steps: { create: [{ description: options.clause, order: 0 }] },
|
|
},
|
|
include: { steps: true },
|
|
});
|
|
const step = recipe.steps[0];
|
|
if (!step) throw new Error("expected a step");
|
|
|
|
const correctedKey = options.correctedKey;
|
|
const correction = await prisma.stepTechStepCorrection.create({
|
|
data: {
|
|
stepId: step.id,
|
|
correctorId: author.id,
|
|
start: 0,
|
|
end: options.clause.length,
|
|
previousTechStepId: null,
|
|
correctedTechStepId: correctedKey === null ? null : await techStepId(correctedKey),
|
|
},
|
|
});
|
|
|
|
let suggestion: { id: number } | null = null;
|
|
if (options.withSuggestion && correctedKey !== null) {
|
|
suggestion = await prisma.techStepTrainingSuggestion.create({
|
|
data: {
|
|
techStepId: await techStepId(correctedKey),
|
|
locale: "fr",
|
|
suggestedSynonyms: options.withSuggestion.synonyms,
|
|
suggestedUtterances: [],
|
|
sourceType: "correction",
|
|
sourceCorrectionId: correction.id,
|
|
status: options.withSuggestion.status,
|
|
},
|
|
});
|
|
}
|
|
return { recipeId: recipe.id, stepId: step.id, correctionId: correction.id, suggestion };
|
|
}
|
|
|
|
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
|
|
|
|
describe("Admin tech-steps triage", () => {
|
|
const app = createApp();
|
|
|
|
beforeEach(async () => {
|
|
await resetDatabase();
|
|
});
|
|
|
|
after(async () => {
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
async function adminAgent() {
|
|
const { email, password } = await seedAdmin();
|
|
const agent = request.agent(app);
|
|
await agent.post("/admin/auth/login").send({ email, password });
|
|
return agent;
|
|
}
|
|
|
|
it("rejects every route without an admin session", async () => {
|
|
for (const path of [
|
|
"/admin/tech-steps/suggestions",
|
|
"/admin/tech-steps/corrections",
|
|
"/admin/tech-steps/training-data-snippet?techStepKey=simmer",
|
|
]) {
|
|
const res = await request(app).get(path);
|
|
expect(res.status, path).to.equal(401);
|
|
}
|
|
const post = await request(app).post("/admin/tech-steps/retrain").send({});
|
|
expect(post.status).to.equal(401);
|
|
});
|
|
|
|
describe("GET /suggestions", () => {
|
|
it("groups suggestions by technique and filters by status", async function () {
|
|
if (!adminSecretConfigured) {
|
|
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
(this as any).skip();
|
|
return;
|
|
}
|
|
await seedCorrectionAndSuggestion({
|
|
clause: "Faire mijoter",
|
|
correctedKey: "simmer",
|
|
withSuggestion: { status: "pending", synonyms: ["laisser frémir"] },
|
|
});
|
|
await seedCorrectionAndSuggestion({
|
|
clause: "Émincer les oignons",
|
|
correctedKey: "chop",
|
|
withSuggestion: { status: "applied", synonyms: ["ciseler"] },
|
|
});
|
|
|
|
const agent = await adminAgent();
|
|
|
|
const all = await agent.get("/admin/tech-steps/suggestions");
|
|
expect(all.status).to.equal(200);
|
|
expect(all.body.map((g: { techStepKey: string }) => g.techStepKey)).to.have.members([
|
|
"chop",
|
|
"simmer",
|
|
]);
|
|
const simmerGroup = all.body.find((g: { techStepKey: string }) => g.techStepKey === "simmer");
|
|
expect(simmerGroup.suggestions[0].sourceCorrection.clauseText).to.equal("Faire mijoter");
|
|
|
|
const pendingOnly = await agent
|
|
.get("/admin/tech-steps/suggestions")
|
|
.query({ status: "pending" });
|
|
expect(pendingOnly.body).to.have.length(1);
|
|
expect(pendingOnly.body[0].techStepKey).to.equal("simmer");
|
|
});
|
|
});
|
|
|
|
describe("PATCH /suggestions/:id", () => {
|
|
it("rejects an empty body with 400", async function () {
|
|
if (!adminSecretConfigured) {
|
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
(this as any).skip();
|
|
return;
|
|
}
|
|
const { suggestion } = await seedCorrectionAndSuggestion({
|
|
clause: "Faire mijoter",
|
|
correctedKey: "simmer",
|
|
withSuggestion: { status: "pending", synonyms: ["x"] },
|
|
});
|
|
const agent = await adminAgent();
|
|
const res = await agent.patch(`/admin/tech-steps/suggestions/${suggestion?.id}`).send({});
|
|
expect(res.status).to.equal(400);
|
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
});
|
|
|
|
it("404s an unknown id", async function () {
|
|
if (!adminSecretConfigured) {
|
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
(this as any).skip();
|
|
return;
|
|
}
|
|
const agent = await adminAgent();
|
|
const res = await agent
|
|
.patch("/admin/tech-steps/suggestions/999999")
|
|
.send({ status: "applied" });
|
|
expect(res.status).to.equal(404);
|
|
});
|
|
|
|
it("flips the status and edits the synonyms, reflected in a later GET", async function () {
|
|
if (!adminSecretConfigured) {
|
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
(this as any).skip();
|
|
return;
|
|
}
|
|
const { suggestion } = await seedCorrectionAndSuggestion({
|
|
clause: "Faire mijoter",
|
|
correctedKey: "simmer",
|
|
withSuggestion: { status: "pending", synonyms: ["frémir"] },
|
|
});
|
|
const agent = await adminAgent();
|
|
|
|
const patched = await agent
|
|
.patch(`/admin/tech-steps/suggestions/${suggestion?.id}`)
|
|
.send({ status: "applied", suggestedSynonyms: ["frémir", "mijoter doucement"] });
|
|
expect(patched.status).to.equal(200);
|
|
expect(patched.body.status).to.equal("applied");
|
|
expect(patched.body.suggestedSynonyms).to.deep.equal(["frémir", "mijoter doucement"]);
|
|
|
|
const stored = await prisma.techStepTrainingSuggestion.findUniqueOrThrow({
|
|
where: { id: suggestion?.id },
|
|
});
|
|
expect(stored.status).to.equal("applied");
|
|
});
|
|
});
|
|
|
|
describe("GET /corrections", () => {
|
|
it("includes the 'no technique here' removals", async function () {
|
|
if (!adminSecretConfigured) {
|
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
(this as any).skip();
|
|
return;
|
|
}
|
|
await seedCorrectionAndSuggestion({ clause: "Rien ici", correctedKey: null });
|
|
await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer" });
|
|
|
|
const agent = await adminAgent();
|
|
const res = await agent.get("/admin/tech-steps/corrections");
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body).to.have.length(2);
|
|
|
|
const removals = await agent
|
|
.get("/admin/tech-steps/corrections")
|
|
.query({ hasCorrectedTechStep: "false" });
|
|
expect(removals.body).to.have.length(1);
|
|
expect(removals.body[0].clauseText).to.equal("Rien ici");
|
|
expect(removals.body[0].correctedTechStepKey).to.equal(null);
|
|
});
|
|
});
|
|
|
|
describe("GET /training-data-snippet", () => {
|
|
it("aggregates the applied suggestions' synonyms into a paste-ready block", async function () {
|
|
if (!adminSecretConfigured) {
|
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
(this as any).skip();
|
|
return;
|
|
}
|
|
await seedCorrectionAndSuggestion({
|
|
clause: "Faire mijoter",
|
|
correctedKey: "simmer",
|
|
withSuggestion: { status: "applied", synonyms: ["frémir", "réduire à feu doux"] },
|
|
});
|
|
const agent = await adminAgent();
|
|
const res = await agent
|
|
.get("/admin/tech-steps/training-data-snippet")
|
|
.query({ techStepKey: "simmer", locale: "fr", status: "applied" });
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body.suggestionCount).to.equal(1);
|
|
expect(res.body.synonyms).to.deep.equal(["frémir", "réduire à feu doux"]);
|
|
expect(res.body.snippet).to.include('"frémir"');
|
|
expect(res.body.snippet).to.include('"synonyms": [');
|
|
});
|
|
});
|
|
|
|
describe("POST /retrain", () => {
|
|
it("runs the F1 gate and returns its result shape (needs tech-step-intent-service)", async function () {
|
|
if (!adminSecretConfigured) {
|
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
(this as any).skip();
|
|
return;
|
|
}
|
|
this.timeout(60000);
|
|
const agent = await adminAgent();
|
|
const res = await agent.post("/admin/tech-steps/retrain").send({});
|
|
expect(res.status).to.equal(200);
|
|
expect(res.body).to.have.keys([
|
|
"f1",
|
|
"precision",
|
|
"recall",
|
|
"minF1",
|
|
"gatePassed",
|
|
"backfilled",
|
|
"marked",
|
|
]);
|
|
expect(res.body.minF1).to.equal(0.8);
|
|
if (res.body.gatePassed) {
|
|
expect(res.body.backfilled).to.have.keys(["total", "changed"]);
|
|
}
|
|
});
|
|
|
|
it("returns 409 RETRAIN_ALREADY_RUNNING while one is in flight", async function () {
|
|
if (!adminSecretConfigured) {
|
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
(this as any).skip();
|
|
return;
|
|
}
|
|
this.timeout(60000);
|
|
const agent = await adminAgent();
|
|
const first = agent.post("/admin/tech-steps/retrain").send({});
|
|
// Let the first handler acquire the process-wide lock before the second starts.
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
const second = await agent.post("/admin/tech-steps/retrain").send({});
|
|
expect(second.status).to.equal(409);
|
|
expect(second.body.code).to.equal(ErrorCode.RETRAIN_ALREADY_RUNNING);
|
|
await first;
|
|
});
|
|
});
|
|
});
|