import { prisma } from "../db/prisma.js"; import { MIN_OVERALL_F1, runTechStepEvalSuite, } from "../lib/recipe-matching/tech-step-eval-runner.js"; import { backfillTechSteps } from "./backfill-tech-steps.js"; /** Parses `--applied=1,2,3`/`--rejected=4,5` from argv into id arrays — both optional, both empty by default (a run with neither flag only re-gates + backfills, doesn't touch any suggestion's status). */ function parseSuggestionIds(flag: "applied" | "rejected"): number[] { const prefix = `--${flag}=`; const arg = process.argv.find((value) => value.startsWith(prefix)); if (arg === undefined) return []; return arg .slice(prefix.length) .split(",") .map((value) => value.trim()) .filter((value) => value.length > 0) .map((value) => { const id = Number(value); if (!Number.isInteger(id)) { throw new Error(`--${flag}: "${value}" is not a valid integer id`); } return id; }); } /** * Maintainer workflow closing the loop on a training-corpus change (see * this feature's plan document): * * 1. A maintainer has already hand-edited `tech-step-training-data.ts` * (informed by `list-pending-training-suggestions.ts`'s report), and * decided which `TechStepTrainingSuggestion` ids they incorporated * (`--applied=`) or explicitly discarded (`--rejected=`). * 2. This script re-runs the F1 regression gate * ({@link runTechStepEvalSuite} against {@link MIN_OVERALL_F1}) — * refuses to backfill at all if the edited corpus scores worse than * the floor, so a bad edit never reaches every existing recipe. * 3. Backfills every `Step`'s `StepTechStep` sequence against the new * corpus ({@link backfillTechSteps}). * 4. Marks the given suggestion ids `applied`/`rejected`, so * `list-pending-training-suggestions.ts`'s next report doesn't * surface them again. * * Usage: * * pnpm --filter api exec tsx src/scripts/retrain-tech-steps.ts --applied=12,13 --rejected=14 * * `--applied`/`--rejected` are both optional — omitting both still runs * the gate + backfill, just leaves every suggestion's `status` untouched * (useful for re-running the backfill alone after a corpus edit made with * no suggestions involved at all). */ async function retrainTechSteps(): Promise { const appliedIds = parseSuggestionIds("applied"); const rejectedIds = parseSuggestionIds("rejected"); console.info("Evaluating the current classifier against the labeled evaluation set..."); const { overall } = await runTechStepEvalSuite(); console.info( `F1 ${overall.f1.toFixed(3)} (precision ${overall.precision.toFixed(3)}, recall ${overall.recall.toFixed(3)})`, ); if (overall.f1 < MIN_OVERALL_F1) { throw new Error( `Aggregate F1 ${overall.f1.toFixed(3)} is below the ${MIN_OVERALL_F1} regression floor — refusing to backfill. Revert or fix the corpus change and re-run.`, ); } const { total, changed } = await backfillTechSteps(); console.info(`Backfilled ${changed}/${total} step(s).`); if (appliedIds.length > 0) { // `updateMany`'s own `count` (rows actually matched/updated), not // `appliedIds.length` (what was merely *asked for*) — an id that // doesn't exist (typo, already-processed id) would otherwise log a // success count that silently doesn't match what really changed. const { count } = await prisma.techStepTrainingSuggestion.updateMany({ where: { id: { in: appliedIds } }, data: { status: "applied" }, }); console.info(`Marked ${count}/${appliedIds.length} suggestion(s) as applied.`); } if (rejectedIds.length > 0) { const { count } = await prisma.techStepTrainingSuggestion.updateMany({ where: { id: { in: rejectedIds } }, data: { status: "rejected" }, }); console.info(`Marked ${count}/${rejectedIds.length} suggestion(s) as rejected.`); } } retrainTechSteps() .then(() => prisma.$disconnect()) .catch(async (err) => { console.error(err); await prisma.$disconnect(); process.exit(1); });