import { pathToFileURL } from "node:url"; import { prisma } from "../db/prisma.js"; import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js"; import { renumberStepTechSteps } from "../modules/recipe/recipe-tech-step-correction.service.js"; /** * Recomputes every existing `Step`'s `"auto"`-sourced `StepTechStep` * entries against the *current* classifier * (`tech-step-matcher.ts`/`tech-step-training-data.ts`), the same way * `updateRecipe` does when a user resaves a recipe through the UI — * always `"fr"` (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`; there's * no persisted per-recipe locale to recover for a step that already * exists, so this matches real resave behavior exactly rather than * guessing). * * Needed because tech-step detection only ever runs at create/update time * (`recipe.service.ts`'s `matchStepsTechSteps`), never retroactively — a * step saved before a classifier/corpus change (new vocabulary, or the * `contextStart`/`contextEnd` columns a previous session added) keeps * whatever it was matched with at the time until it's next resaved. * * `"manual"`-sourced entries (a viewer's correction, applied immediately — * see `recipe-tech-step-correction.service.ts`'s `applyManualCorrection`) * are never touched by this: only rows with `source: "auto"` are deleted * and recreated, and any fresh classifier match overlapping an existing * `"manual"` entry's span is dropped rather than inserted — a manual * correction is meant to *override* the classifier at that exact spot, * and recomputing must never silently reintroduce (or duplicate-highlight) * what a user already corrected. `renumberStepTechSteps` * (`recipe-tech-step-correction.service.ts`) folds the surviving `"auto"` + * untouched `"manual"` rows back into one coherent reading-order sequence * afterward. * * Exported (not just called from this file's own CLI guard below) so * `retrain-tech-steps.ts` can run it as one step of its own larger * maintainer workflow, without shelling out to a second process. * * Safe to re-run: with no manual entries and no corpus change since the * last run, this is a no-op (the same `"auto"` matches get deleted and * recreated identically); with manual entries present, they're preserved * on every run by construction. */ export async function backfillTechSteps(): Promise<{ total: number; changed: number }> { const steps = await prisma.step.findMany({ select: { id: true, description: true } }); console.info(`Recomputing tech steps for ${steps.length} step(s)...`); let changed = 0; for (const step of steps) { const matches = await techStepClassifier.matchTechStepSpans(step.description, "fr"); await prisma.$transaction(async (tx) => { const manualRows = await tx.stepTechStep.findMany({ where: { stepId: step.id, source: "manual" }, }); const nonOverlappingMatches = matches.filter( (match) => !manualRows.some( (manual) => manual.start !== null && manual.end !== null && manual.start < match.end && match.start < manual.end, ), ); await tx.stepTechStep.deleteMany({ where: { stepId: step.id, source: "auto" } }); if (nonOverlappingMatches.length > 0) { // Placeholder orders, disjoint from the untouched manual rows' // existing ones (`renumberStepTechSteps` below folds everything // into a clean 0..N-1 sequence right after — these just need to // not collide with `@@id([stepId, order])` for this insert). const startOrder = manualRows.reduce((max, row) => Math.max(max, row.order), -1) + 1; await tx.stepTechStep.createMany({ data: nonOverlappingMatches.map((match, index) => ({ stepId: step.id, techStepId: match.techStepId, order: startOrder + index, start: match.start, end: match.end, contextStart: match.contextStart, contextEnd: match.contextEnd, source: "auto", })), }); } await renumberStepTechSteps(tx, step.id); }); changed += 1; } console.info(`Done — ${changed} step(s) recomputed.`); return { total: steps.length, changed }; } // Only runs when this file is executed directly (`tsx // src/scripts/backfill-tech-steps.ts`), not when `retrain-tech-steps.ts` // imports `backfillTechSteps` above — the standard ESM "is this the entry // module" check, first needed in this codebase by that new script; every // prior script here (`seed-runtime.ts`) was always only ever run directly, // never imported. `pathToFileURL` (not a naive `` `file://${process.argv[1]}` `` // concatenation) is required for this to actually work on Windows — a // native Windows path (backslashes, no leading slash before the drive // letter) doesn't survive being pasted directly after `file://`, so the // comparison against `import.meta.url` (already a real, correctly-escaped // `file:///D:/...` URL) always came out false: this guard silently never // matched, so running this script directly (`tsx // src/scripts/backfill-tech-steps.ts`) did *nothing* — no error, no // output, `backfillTechSteps()` simply never called — found only by // running it for real and noticing zero output where several log lines // were expected. const isMainModule = import.meta.url === pathToFileURL(process.argv[1] ?? "").href; if (isMainModule) { backfillTechSteps() .then(() => prisma.$disconnect()) .catch(async (err) => { console.error(err); await prisma.$disconnect(); process.exit(1); }); }