Les corrections utilisateur (via TechStepCorrectionPopover) sont
désormais écrites directement dans StepTechStep, avec une colonne
`source` ("auto" | "manual") qui les distingue des matches du
classifieur NLP :
- Migration `step_tech_step_source` ajoutant `source` (défaut "auto")
- `applyManualCorrection`/`renumberStepTechSteps` dans
recipe-tech-step-correction.service.ts : une correction met à jour
ou crée l'entrée StepTechStep concernée (source "manual"), la
réponse de l'endpoint inclut désormais le techSteps à jour du step
(SubmitTechStepCorrectionResult), pas seulement l'audit de
correction
- backfill-tech-steps.ts préserve les entrées "manual" existantes :
seules les entrées "auto" sont recalculées, et un nouveau match
auto chevauchant une correction manuelle est ignoré plutôt
qu'inséré en doublon — vérifié en base réelle (une correction
manuelle survit intacte à un backfill complet)
- Le front distingue visuellement les deux (StepDescription.tsx,
recipes.scss : `.step-tech-step--manual`, couleur Turmeric au lieu
de Basil), avec un tooltip "(correction manuelle)" et un indicateur
de découvrabilité de la fonctionnalité dans RecipeDetailPanel
Corrige aussi deux bugs trouvés en testant en conditions réelles :
- StepDescription.tsx : le clic sur un highlight existant lisait la
variable `offset` (mutable, partagée par la boucle) au lieu d'une
valeur capturée, envoyant un `end` erroné (fin de la description
entière au lieu du span du mot cliqué)
- backfill-tech-steps.ts : le garde `import.meta.url ===
file://${process.argv[1]}` ne matche jamais sur Windows (chemins à
antislash), le script ne faisait donc rien en exécution directe ;
remplacé par `pathToFileURL(process.argv[1]).href`
335 tests apps/api passants, 40/40 composants Cypress, 75/76 e2e
Cypress (1 flake pré-existant sans rapport, non touché ici).
123 lines
5.6 KiB
TypeScript
123 lines
5.6 KiB
TypeScript
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);
|
|
});
|
|
}
|