fix(api): corrige la découpe des clauses et le seuil de confiance du classifieur de tech steps

Trouvé en examinant des vraies recettes déjà en base après le dernier
étoffement du vocabulaire : plusieurs étapes bien réelles se faisaient
classer sur la mauvaise technique, sans lien avec un mot-clé manquant.

- splitIntoClauses coupe désormais sur la limite de phrase (juste après
  un ".", "!" ou "?") la plus proche du milieu de l'écart entre deux
  candidats quand il y en a une, plutôt que sur l'espace brut le plus
  proche du milieu. Une description à deux techniques dans deux phrases
  distinctes ("Préchauffer le four à 180°C. Dans un saladier, mettre le
  beurre... et mélanger.") ne coupait qu'au milieu brut, ce qui pouvait
  trancher en pleine deuxième phrase et envoyer au classifieur une
  clause tronquée ("...(thermostat 6). Dans un saladier, mettre" sans
  complément) — assez éloignée des phrases d'entraînement courtes et
  complètes pour se faire mal classer avec confiance (préchauffer prédit
  "mix", mélanger prédit "melt").
- CONFIDENCE_THRESHOLD passe de 0.65 à 0.75 : du texte anglais passé
  dans le classifieur français (qui doit ne rien trouver, garanti par
  le test d'isolation des locales) scorait 0.69 sur "boil" — du bruit
  de petit corpus, pas un vrai verdict. Les cas réels que ce seuil sert
  à faire confiance scorent 0.91 à 1.0 en pratique ; 0.75 sépare
  proprement le bruit du signal sans rien casser (309 tests toujours
  verts).
- Deux phrases d'entraînement ajoutées à `cook` pour deux clauses
  réelles mal classées (feu doux + remuant, découvert + laisser cuire)
  qui n'avaient pourtant pas de mot-clé manquant.

Ajoute aussi src/scripts/backfill-tech-steps.ts : la détection ne
tourne qu'à la création/modification d'une recette, jamais
rétroactivement — ce script recalcule le start/end/contextStart/
contextEnd de chaque étape existante contre le classifieur actuel,
pour ne pas avoir à rouvrir et resauvegarder chaque recette à la main
après un changement de corpus.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-21 18:15:07 +02:00
parent 8d741ed13f
commit 76175bcdbf
3 changed files with 135 additions and 17 deletions

View file

@ -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}

View file

@ -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: {

View file

@ -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<void> {
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);
});