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:
parent
8d741ed13f
commit
76175bcdbf
3 changed files with 135 additions and 17 deletions
|
|
@ -115,32 +115,63 @@ export interface TechStepClause {
|
||||||
anchor: TechniqueCandidate | null;
|
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
|
* Picks where to cut the gap `[gapStart, gapEnd)` between two consecutive
|
||||||
* candidates — the whitespace character nearest the gap's raw midpoint, so
|
* candidates — preferring a *sentence* boundary (right after `.`/`!`/`?`)
|
||||||
* a clause boundary (surfaced to users as `contextStart`/`contextEnd`,
|
* nearest the gap's midpoint when one exists in the gap, otherwise any
|
||||||
* unlike a keyword's own `[start, end)` which always lands on a real word
|
* whitespace nearest the midpoint, so a clause boundary (surfaced to users
|
||||||
* by construction) never slices through the middle of a word — found while
|
* as `contextStart`/`contextEnd`, unlike a keyword's own `[start, end)`
|
||||||
* testing a context span that cut "poêle" into "poêl"/"e" across two
|
* which always lands on a real word by construction) never slices through
|
||||||
* clauses. Falls back to the raw midpoint when the gap has no whitespace
|
* the middle of a word — found while testing a context span that cut
|
||||||
* at all (adjacent candidates, or a gap that's pure punctuation with no
|
* "poêle" into "poêl"/"e" across two clauses.
|
||||||
* space) — same "some split point, however imperfect" fallback a plain
|
*
|
||||||
* midpoint always was.
|
* 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 {
|
function findGapSplitPoint(description: string, gapStart: number, gapEnd: number): number {
|
||||||
if (gapStart >= gapEnd) return gapStart;
|
if (gapStart >= gapEnd) return gapStart;
|
||||||
const midpoint = Math.floor((gapStart + gapEnd) / 2);
|
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++) {
|
for (let i = gapStart; i < gapEnd; i++) {
|
||||||
if (!/\s/.test(description[i] ?? "")) continue;
|
if (!/\s/.test(description[i] ?? "")) continue;
|
||||||
const distance = Math.abs(i - midpoint);
|
const distance = Math.abs(i - midpoint);
|
||||||
if (distance < bestDistance) {
|
if (distance < bestWhitespaceDistance) {
|
||||||
best = i;
|
bestWhitespace = i;
|
||||||
bestDistance = distance;
|
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;
|
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} —
|
* Trains and owns the `node-nlp` model behind {@link matchTechStepSpans} —
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,16 @@ export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
"la cuisson dure environ dix minutes",
|
"la cuisson dure environ dix minutes",
|
||||||
"jusqu'à ce que la viande ne soit plus rose au centre",
|
"jusqu'à ce que la viande ne soit plus rose au centre",
|
||||||
"poursuivre la cuisson à couvert",
|
"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: {
|
en: {
|
||||||
|
|
|
||||||
60
apps/api/src/scripts/backfill-tech-steps.ts
Normal file
60
apps/api/src/scripts/backfill-tech-steps.ts
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue