batchCooking/services/tech-step-llm-worker/test/jobs/audit-low-confidence.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

123 lines
3.9 KiB
TypeScript

import { expect } from "chai";
import { runAuditLowConfidenceJob } from "../../src/jobs/audit-low-confidence.js";
/**
* Stubs `globalThis.fetch` directly (this worker's own `api-client.ts` is
* a thin wrapper around it) — same convention `apps/api`'s
* `the-meal-db.ts` test uses for the same reason: no real network call,
* no mocking library needed for a single-function dependency.
*/
function stubFetch(responses: Record<string, unknown>): { url: string; body: unknown }[] {
const calls: { url: string; body: unknown }[] = [];
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
const href = String(url);
const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
calls.push({ url: href, body });
for (const [pathFragment, response] of Object.entries(responses)) {
if (href.includes(pathFragment)) {
return new Response(JSON.stringify(response), { status: 200 });
}
}
throw new Error(`stubFetch: no response configured for ${href}`);
}) as typeof fetch;
return calls;
}
describe("runAuditLowConfidenceJob", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("proposes a suggestion only when the LLM disagrees with the NLP anchor", async () => {
const calls = stubFetch({
"audit-batch": [
{
stepId: 1,
recipeId: 1,
clauseText: "jusqu'à ce que ce soit doré",
anchorKey: "fry",
intentKey: null,
score: 0.5,
locale: "fr",
},
{
stepId: 2,
recipeId: 1,
clauseText: "laisser reposer un instant",
anchorKey: "rest",
intentKey: "rest",
score: 0.6,
locale: "fr",
},
],
"training-suggestions": { created: 1 },
});
const llm = {
judgeClause: async (text: string) => (text.includes("doré") ? "roast" : "rest"),
};
const count = await runAuditLowConfidenceJob(llm, { locale: "fr", limit: 10 });
expect(count).to.equal(1);
const postCall = calls.find((call) => call.url.includes("training-suggestions"));
if (!postCall) throw new Error("expected a POST to training-suggestions");
expect(postCall.body).to.deep.equal({
suggestions: [
{
techStepKey: "roast",
locale: "fr",
suggestedSynonyms: [],
suggestedUtterances: ["jusqu'à ce que ce soit doré"],
sourceType: "llm_audit",
},
],
});
});
it("posts nothing when the LLM agrees with the anchor or has no opinion", async () => {
const calls = stubFetch({
"audit-batch": [
{
stepId: 1,
recipeId: 1,
clauseText: "agrees",
anchorKey: "cook",
intentKey: "cook",
score: 0.5,
locale: "fr",
},
{
stepId: 2,
recipeId: 1,
clauseText: "no opinion",
anchorKey: "boil",
intentKey: null,
score: 0.4,
locale: "fr",
},
],
});
const llm = {
judgeClause: async (text: string) => (text === "agrees" ? "cook" : null),
};
const count = await runAuditLowConfidenceJob(llm, { locale: "fr", limit: 10 });
expect(count).to.equal(0);
expect(calls.some((call) => call.url.includes("training-suggestions"))).to.equal(false);
});
it("passes locale/limit through to GET /internal/tech-steps/audit-batch", async () => {
const calls = stubFetch({ "audit-batch": [] });
const llm = { judgeClause: async () => null };
await runAuditLowConfidenceJob(llm, { locale: "en", limit: 7 });
const getCall = calls.find((call) => call.url.includes("audit-batch"));
if (!getCall) throw new Error("expected a GET to audit-batch");
expect(getCall.url).to.include("locale=en");
expect(getCall.url).to.include("limit=7");
});
});