diff --git a/apps/api/src/lib/recipe-matching/tech-step-matcher.ts b/apps/api/src/lib/recipe-matching/tech-step-matcher.ts index afd14e1..9f7b580 100644 --- a/apps/api/src/lib/recipe-matching/tech-step-matcher.ts +++ b/apps/api/src/lib/recipe-matching/tech-step-matcher.ts @@ -115,32 +115,63 @@ export interface TechStepClause { anchor: TechniqueCandidate | null; } +/** Matches a sentence-ending punctuation mark, for {@link findGapSplitPoint}'s preferred split points. */ +const SENTENCE_END_PATTERN = /[.!?]/; + /** * Picks where to cut the gap `[gapStart, gapEnd)` between two consecutive - * candidates — the whitespace character nearest the gap's raw midpoint, so - * a clause boundary (surfaced to users as `contextStart`/`contextEnd`, - * unlike a keyword's own `[start, end)` which always lands on a real word - * by construction) never slices through the middle of a word — found while - * testing a context span that cut "poêle" into "poêl"/"e" across two - * clauses. Falls back to the raw midpoint when the gap has no whitespace - * at all (adjacent candidates, or a gap that's pure punctuation with no - * space) — same "some split point, however imperfect" fallback a plain - * midpoint always was. + * candidates — preferring a *sentence* boundary (right after `.`/`!`/`?`) + * nearest the gap's midpoint when one exists in the gap, otherwise any + * whitespace nearest the midpoint, so a clause boundary (surfaced to users + * as `contextStart`/`contextEnd`, unlike a keyword's own `[start, end)` + * which always lands on a real word by construction) never slices through + * the middle of a word — found while testing a context span that cut + * "poêle" into "poêl"/"e" across two clauses. + * + * The sentence-boundary preference matters beyond cosmetics: a description + * with two techniques in two different sentences ("Préchauffer le four à + * 180°C. Dans un saladier, mettre le beurre... et mélanger.") used to only + * get a plain nearest-midpoint whitespace split, which for a long first + * sentence lands *inside* the second one — handing the classifier a clause + * like "...(thermostat 6). Dans un saladier, mettre" that trails off + * mid-instruction with no object. That garbled, incomplete text is nothing + * like the short, complete training utterances, and was found to + * misclassify real recipe steps with high (>0.65) confidence in both + * halves — "Préchauffer..." scored as `mix`, its actual "mélanger" clause + * as `melt`. Splitting at the real sentence boundary instead hands the + * classifier two complete, grammatical clauses, each far closer to what it + * was trained on. + * + * Falls back to the raw midpoint when the gap has no whitespace at all + * (adjacent candidates, or a gap that's pure punctuation with no space) — + * same "some split point, however imperfect" fallback a plain midpoint + * always was. */ function findGapSplitPoint(description: string, gapStart: number, gapEnd: number): number { if (gapStart >= gapEnd) return gapStart; const midpoint = Math.floor((gapStart + gapEnd) / 2); - let best: number | null = null; - let bestDistance = Number.POSITIVE_INFINITY; + + let bestSentenceEnd: number | null = null; + let bestSentenceEndDistance = Number.POSITIVE_INFINITY; + let bestWhitespace: number | null = null; + let bestWhitespaceDistance = Number.POSITIVE_INFINITY; for (let i = gapStart; i < gapEnd; i++) { if (!/\s/.test(description[i] ?? "")) continue; const distance = Math.abs(i - midpoint); - if (distance < bestDistance) { - best = i; - bestDistance = distance; + if (distance < bestWhitespaceDistance) { + bestWhitespace = i; + bestWhitespaceDistance = distance; + } + if ( + i > gapStart && + SENTENCE_END_PATTERN.test(description[i - 1] ?? "") && + distance < bestSentenceEndDistance + ) { + bestSentenceEnd = i; + bestSentenceEndDistance = distance; } } - return best ?? midpoint; + return bestSentenceEnd ?? bestWhitespace ?? midpoint; } /** @@ -203,8 +234,25 @@ export function splitIntoClauses( return clauses; } -/** Below this confidence, a clause's classifier verdict isn't trusted on its own — falls back to its NER anchor's own technique instead (see this file's doc comment, point 3). Tuned empirically against `TECH_STEP_TRAINING_DATA` — see `test/tech-step-matcher.test.ts` for the cases this threshold was picked to pass. */ -const CONFIDENCE_THRESHOLD = 0.65; +/** + * Below this confidence, a clause's classifier verdict isn't trusted on its + * own — falls back to its NER anchor's own technique instead (see this + * file's doc comment, point 3). Tuned empirically against + * `TECH_STEP_TRAINING_DATA` — see `test/tech-step-matcher.test.ts` for the + * cases this threshold was picked to pass. + * + * Raised from `0.65` after finding real (non-adversarial) misclassified + * clauses that scored just above the old threshold — e.g. English recipe + * text run through the French classifier (which must find *nothing*, + * confirmed by `recipe-translation.test.ts`'s own locale-isolation test) + * scored `0.69` for `boil`, essentially classifier noise on + * out-of-vocabulary input rather than a real, confident verdict. The + * clauses this threshold exists to actually trust score far higher in + * practice (`0.91`–`1.0` for the real corrected cases found this session) + * — `0.75` sits comfortably above the noise floor and below every genuine + * match seen so far. + */ +const CONFIDENCE_THRESHOLD = 0.75; /** * Trains and owns the `node-nlp` model behind {@link matchTechStepSpans} — diff --git a/apps/api/src/lib/recipe-matching/tech-step-training-data.ts b/apps/api/src/lib/recipe-matching/tech-step-training-data.ts index 24b596f..b4e6019 100644 --- a/apps/api/src/lib/recipe-matching/tech-step-training-data.ts +++ b/apps/api/src/lib/recipe-matching/tech-step-training-data.ts @@ -73,6 +73,16 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [ "la cuisson dure environ dix minutes", "jusqu'à ce que la viande ne soit plus rose au centre", "poursuivre la cuisson à couvert", + // Two real recipe clauses found misclassified (as `preheat` and + // `panFry` respectively, both above the confidence threshold) once + // real, longer, comma-heavy sentences started reaching the + // classifier — neither error came from a missing keyword (both + // clauses' own NER anchor, "laisser cuire"/"faire cuire", was + // already right), just the classifier's low-heat/occasional- + // stirring phrasing not resembling anything short and clean-cut it + // had actually been trained on. + "baisser le feu et laisser cuire à découvert encore un quart d'heure", + "faire cuire à feu doux en remuant de temps en temps", ], }, en: { diff --git a/apps/api/src/scripts/backfill-tech-steps.ts b/apps/api/src/scripts/backfill-tech-steps.ts new file mode 100644 index 0000000..519f107 --- /dev/null +++ b/apps/api/src/scripts/backfill-tech-steps.ts @@ -0,0 +1,60 @@ +import { prisma } from "../db/prisma.js"; +import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js"; + +/** + * One-off maintenance script: recomputes every existing `Step`'s + * `StepTechStep` sequence 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 this same session added) keeps + * whatever it was matched with at the time until it's next resaved. Run + * this after a corpus change to bring every existing step in sync without + * asking users to open and resave every recipe by hand: + * + * pnpm --filter api exec tsx src/scripts/backfill-tech-steps.ts + * + * Safe to re-run: each step's technique sequence is fully replaced (delete + * + recreate) from the classifier's current output, same as a real edit — + * running it twice in a row with no corpus change in between is a no-op. + */ +async function backfillTechSteps(): Promise { + 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([ + prisma.stepTechStep.deleteMany({ where: { stepId: step.id } }), + prisma.stepTechStep.createMany({ + data: matches.map((match, order) => ({ + stepId: step.id, + techStepId: match.techStepId, + order, + start: match.start, + end: match.end, + contextStart: match.contextStart, + contextEnd: match.contextEnd, + })), + }), + ]); + changed += 1; + } + + console.info(`Done — ${changed} step(s) recomputed.`); +} + +backfillTechSteps() + .then(() => prisma.$disconnect()) + .catch(async (err) => { + console.error(err); + await prisma.$disconnect(); + process.exit(1); + });