import { prisma } from "../db/prisma.js"; import { TECH_STEP_EVAL_DATASET } from "../lib/recipe-matching/tech-step-eval-dataset.js"; import { computeTechStepMetrics, type TechStepEvalOutcome, } from "../lib/recipe-matching/tech-step-evaluator.js"; import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js"; /** * Candidate thresholds to sweep, `0.05` to `0.95` in `0.05` steps — fine * enough to find a good value without an unreasonable number of full * `TECH_STEP_EVAL_DATASET` passes (each threshold only needs one * {@link techStepClassifier.classifyClauses} call per eval case, not a * retrain — see this file's own doc comment for why). */ const CANDIDATE_THRESHOLDS = Array.from({ length: 19 }, (_, i) => Math.round((i + 1) * 5) / 100); /** * One-off maintainer tool for recalibrating `CONFIDENCE_THRESHOLD` * (`tech-step-matcher.ts`) after a change to the underlying intent * classifier — most notably, the migration from `node-nlp` to * `services/tech-step-intent-service` (spaCy): a different model produces a * differently-shaped confidence score distribution, so a threshold tuned * against the old classifier has no reason to still be the right cutoff for * the new one. * * Reuses `techStepClassifier.classifyClauses` — already public, and * deliberately *not* threshold-applied (see that method's own doc comment) * — to get every eval case's raw `{anchorUid, intentUid, score}` per clause * exactly once, then replays `_classifyClause`'s own decision rule * (`intentUid` if confident enough, `anchorUid` otherwise) locally in this * script for every candidate threshold. This is what makes a full sweep * cheap: one classifier pass per eval case regardless of how many * thresholds are being compared, rather than one full pass *per threshold*. * * Prints a threshold -> precision/recall/F1 table and the threshold that * maximizes aggregate F1 — does **not** edit `tech-step-matcher.ts` itself. * A maintainer reads the table, updates `CONFIDENCE_THRESHOLD` by hand (with * an updated doc comment recording what run/F1 the new value was calibrated * against, same as the existing comment's own format), then re-runs * `retrain-tech-steps.ts` to confirm the change clears `MIN_OVERALL_F1`. * * Usage: * * pnpm --filter api exec tsx src/scripts/calibrate-tech-step-threshold.ts */ async function calibrateTechStepThreshold(): Promise { console.info(`Classifying ${TECH_STEP_EVAL_DATASET.length} eval case(s)...`); // One classifier pass per eval case, all clauses' raw verdicts kept // alongside the case's own `expectedKeys` — reused for every candidate // threshold in the loop below. const casesWithClauses = await Promise.all( TECH_STEP_EVAL_DATASET.map(async (evalCase) => ({ expectedKeys: evalCase.expectedKeys, clauses: await techStepClassifier.classifyClauses(evalCase.description, evalCase.locale), })), ); console.info("\nthreshold precision recall f1"); let bestThreshold = CANDIDATE_THRESHOLDS[0] ?? 0; let bestF1 = -1; for (const threshold of CANDIDATE_THRESHOLDS) { const outcomes: TechStepEvalOutcome[] = casesWithClauses.map(({ expectedKeys, clauses }) => { const actualKeys = clauses // Mirrors `_classifyClause`'s own decision rule exactly (see that // method, `tech-step-matcher.ts`) — the classifier's own verdict // when confident enough, otherwise its clause's NER anchor, `null` // when neither applies (no keyword, no confident classification). .map((clause) => clause.intentUid !== null && clause.score >= threshold ? clause.intentUid : clause.anchorUid, ) .filter((key): key is string => key !== null); return { expectedKeys, actualKeys }; }); const { overall } = computeTechStepMetrics(outcomes); console.info( `${threshold.toFixed(2)} ${overall.precision.toFixed(3)} ${overall.recall.toFixed(3)} ${overall.f1.toFixed(3)}`, ); if (overall.f1 > bestF1) { bestF1 = overall.f1; bestThreshold = threshold; } } console.info( `\nBest aggregate F1 ${bestF1.toFixed(3)} at threshold ${bestThreshold.toFixed(2)} — update CONFIDENCE_THRESHOLD in tech-step-matcher.ts by hand if this differs from the current value.`, ); } calibrateTechStepThreshold() .then(() => prisma.$disconnect()) .catch(async (err) => { console.error(err); await prisma.$disconnect(); process.exit(1); });