fix(tech-steps): empêche le contexte d'un match d'avaler une correction manuelle voisine
La correction manuelle ne s'affichait pas quand elle portait sur du texte
qui n'était pas une technique à l'origine — reproduit en live : une
description avec un seul match auto-détecté ("mijoter") voit son
contexte de clause s'étendre sur toute la description dès que
splitIntoClauses (tech-step-matcher.ts) n'a trouvé qu'un seul candidat
NER (le cas courant), même quand ce candidat n'a aucun rapport avec le
reste du texte. splitDescriptionByTechSteps avançait alors son curseur
jusqu'à la fin de ce contexte large, ce qui faisait purement et
simplement disparaître (silencieusement, sans erreur) toute correction
manuelle ajoutée plus loin dans la même description — un mot pourtant
sans aucun rapport avec la technique auto-détectée.
Le contexte d'un match est purement cosmétique (StepDescription.tsx le
rend identique à du texte brut depuis que sa mise en valeur dédiée a
été désactivée) et ne doit donc jamais coûter son propre highlight à
un *autre* match. splitDescriptionByTechSteps distingue maintenant
deux notions : le chevauchement entre les spans *keyword* stricts de
deux entrées (toujours un vrai conflit, l'entrée la plus tardive est
toujours ignorée, comportement inchangé) et le chevauchement du
contexte *cosmétique* d'une entrée sur le keyword d'une autre (jamais
un vrai conflit désormais : le contexte est simplement rogné pour
laisser la place, plutôt que l'entrée voisine entière étant abandonnée).
Vérifié en conditions réelles (Docker) : une correction manuelle sur
"materiel" dans "Faire mijoter la sauce, puis ranger le materiel."
s'affiche maintenant correctement à côté du highlight auto "mijoter",
et survit à un rechargement complet de la page.
Nouveau test de régression dans highlight-tech-steps.cy.tsx
reproduisant exactement ce cas ; les 18 tests du fichier (dont tous
les cas de contexte/malformation déjà couverts) passent toujours.
This commit is contained in:
parent
1d69a61b37
commit
e6bb9e2d50
2 changed files with 77 additions and 16 deletions
|
|
@ -140,6 +140,29 @@ describe("splitDescriptionByTechSteps", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("never lets one match's wider context swallow another match's own keyword span", () => {
|
||||
// The motivating real bug (found via live testing, not invented for
|
||||
// this test): "simmer" is the only NER candidate `splitIntoClauses`
|
||||
// found, so its context spans the *entire* description — before this
|
||||
// was fixed, that wide context advanced `cursor` past 39, silently
|
||||
// dropping "setAside"'s own keyword span (a manual correction on
|
||||
// "materiel", a word with no relation to "simmer" at all) instead of
|
||||
// rendering it.
|
||||
const text = "Faire mijoter la sauce, puis ranger le materiel.";
|
||||
const result = splitDescriptionByTechSteps(text, [
|
||||
techStep("simmer", 1, 6, 13, { start: 0, end: 48 }),
|
||||
techStep("setAside", 2, 39, 47, undefined, "manual"),
|
||||
]);
|
||||
const keywordSegments = result.filter((s) => s.isKeyword);
|
||||
expect(
|
||||
keywordSegments.map((s) => ({ key: s.techStep?.key, text: s.text, source: s.source })),
|
||||
).to.deep.equal([
|
||||
{ key: "simmer", text: "mijoter", source: "auto" },
|
||||
{ key: "setAside", text: "materiel", source: "manual" },
|
||||
]);
|
||||
expect(result.map((s) => s.text).join("")).to.equal(text);
|
||||
});
|
||||
|
||||
describe("with a context span wider than the keyword", () => {
|
||||
it("splits into context-before / keyword / context-after around a keyword in the middle of its clause", () => {
|
||||
// The motivating example: "Dans une poêle chaude, faire chauffer une
|
||||
|
|
|
|||
|
|
@ -28,34 +28,54 @@ export interface DescriptionSegment {
|
|||
* (the keyword) and, when present, `contextStart`/`contextEnd` (the wider
|
||||
* clause it was found in — see `StepTechStepView`, resolved server-side by
|
||||
* `tech-step-matcher.ts`'s `matchTechStepSpans`). An entry with no context
|
||||
* (older data, saved before that column pair existed — see
|
||||
* `StepTechStep`'s schema doc comment) degrades to a keyword-only segment,
|
||||
* same as before context spans existed at all.
|
||||
* (older data, saved before that column pair existed, or a manual
|
||||
* correction — see `StepTechStep`'s schema doc comment) degrades to a
|
||||
* keyword-only segment, same as before context spans existed at all.
|
||||
*
|
||||
* `techSteps` is expected already sorted by `start` (the API returns it in
|
||||
* `StepTechStep.order`, which *is* reading order — see that model's schema
|
||||
* doc comment) but this re-sorts defensively (by context start when
|
||||
* present, since context always starts at or before its own keyword)
|
||||
* rather than assuming it, and silently drops any entry whose bounds don't
|
||||
* make sense against `description` or a previously-accepted entry's own
|
||||
* bounds — a malformed/out-of-date span degrades to "just don't highlight
|
||||
* that one" rather than a garbled slice or a crash.
|
||||
* doc comment) but this re-sorts defensively by each entry's own tight
|
||||
* `start` rather than assuming it, and silently drops any entry whose own
|
||||
* bounds don't make sense against `description` or a previously-accepted
|
||||
* entry's own tight keyword span — a malformed/out-of-date span degrades to
|
||||
* "just don't highlight that one" rather than a garbled slice or a crash.
|
||||
*
|
||||
* Two entries' tight keyword spans are never allowed to overlap (the later
|
||||
* one is dropped, same as always), but two entries' wider *context* clauses
|
||||
* are allowed to overlap each other and are silently clipped to make room —
|
||||
* context is cosmetic only (`StepDescription.tsx` renders it identically to
|
||||
* plain text) and must never cost a *different* entry its own real keyword
|
||||
* highlight. This matters far more than it looks: a clause `splitIntoClauses`
|
||||
* (`tech-step-matcher.ts`) found only one NER candidate in gets that
|
||||
* candidate's context spanning the *entire* clause — often the entire
|
||||
* description — so without clipping, a single auto-detected match anywhere
|
||||
* in a step could silently swallow every manual correction added anywhere
|
||||
* else in that same step's description, with no error, just an unstyled
|
||||
* word in the rendered text. Found via live testing: a "simmer" match's
|
||||
* whole-description context ate a manual "setAside" correction added to a
|
||||
* later, otherwise-plain word in the same step.
|
||||
*/
|
||||
export function splitDescriptionByTechSteps(
|
||||
description: string,
|
||||
techSteps: StepTechStepView[],
|
||||
): DescriptionSegment[] {
|
||||
const sorted = [...techSteps].sort(
|
||||
(a, b) => (a.contextStart ?? a.start) - (b.contextStart ?? b.start),
|
||||
);
|
||||
const sorted = [...techSteps].sort((a, b) => a.start - b.start);
|
||||
|
||||
const segments: DescriptionSegment[] = [];
|
||||
let cursor = 0;
|
||||
for (const { techStep, start, end, contextStart, contextEnd, source } of sorted) {
|
||||
// First pass: decide which entries survive at all, using only each
|
||||
// entry's own tight keyword span for the cross-entry overlap check
|
||||
// (`start < keywordCursor`) — the one thing two independent matches are
|
||||
// never allowed to genuinely share. An entry's own context is still
|
||||
// validated against its *own* keyword span here (`wideStart > start`,
|
||||
// `end > wideEnd`, `wideEnd > description.length`) — a self-inconsistent
|
||||
// span is dropped regardless of any other entry.
|
||||
const valid: StepTechStepView[] = [];
|
||||
let keywordCursor = 0;
|
||||
for (const entry of sorted) {
|
||||
const { start, end, contextStart, contextEnd } = entry;
|
||||
const wideStart = contextStart ?? start;
|
||||
const wideEnd = contextEnd ?? end;
|
||||
if (
|
||||
wideStart < cursor ||
|
||||
start < keywordCursor ||
|
||||
wideStart > start ||
|
||||
start >= end ||
|
||||
end > wideEnd ||
|
||||
|
|
@ -63,6 +83,24 @@ export function splitDescriptionByTechSteps(
|
|||
) {
|
||||
continue;
|
||||
}
|
||||
valid.push(entry);
|
||||
keywordCursor = end;
|
||||
}
|
||||
|
||||
const segments: DescriptionSegment[] = [];
|
||||
let cursor = 0;
|
||||
for (const [index, entry] of valid.entries()) {
|
||||
const { techStep, start, end, contextStart, contextEnd, source } = entry;
|
||||
const next = valid[index + 1];
|
||||
// Clipped against `cursor` (this entry can't render context over
|
||||
// territory already emitted) and the next surviving entry's own tight
|
||||
// `start` (this entry's context can't reach into a neighbor's real
|
||||
// keyword span) — provably within `[cursor, start]`/`[end, next.start]`
|
||||
// respectively given `valid`'s own non-overlapping-tight-span
|
||||
// invariant from the first pass, so never produces a negative-length
|
||||
// slice.
|
||||
const wideStart = Math.max(contextStart ?? start, cursor);
|
||||
const wideEnd = Math.min(contextEnd ?? end, next?.start ?? description.length);
|
||||
|
||||
if (wideStart > cursor) {
|
||||
segments.push({
|
||||
|
|
|
|||
Loading…
Reference in a new issue