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>
123 lines
3.9 KiB
TypeScript
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");
|
|
});
|
|
});
|