import type { StepTechStepTemperatureView, StepTechStepView } from "@batch-cooking/shared"; /** * One run of a step's `description` — either plain text, or part of a * detected technique (`techStep` set). A technique's own text is itself * split into up to three runs (see {@link splitDescriptionByTechSteps}): * the tight keyword span (`isKeyword: true`, e.g. "préchauffer") and, when * `StepTechStepView.contextStart`/`contextEnd` are present, the wider * surrounding clause around it (`isKeyword: false`, e.g. "Dans une poêle * chaude" around a keyword of "poêle chaude"). `StepDescription.tsx` * currently renders `isKeyword: false` segments as plain text (no visual * distinction from a segment with no technique at all) — the context split * still happens here so the data stays available, but its own dedicated * highlight was turned back off; see that component's doc comment. */ export interface DescriptionSegment { text: string; techStep: StepTechStepView["techStep"] | null; /** Always `false` when `techStep` is `null`. */ isKeyword: boolean; /** Mirrors the source `StepTechStepView.source` this segment came from — `null` when `techStep` is `null` (nothing to attribute a source to). See `StepDescription.tsx` for how `"auto"` vs `"manual"` render differently. */ source: StepTechStepView["source"] | null; /** * Set when this run is a detected temperature mention (e.g. "180°C", * "feu doux") — only ever populated by {@link splitDescriptionSegments}, * never by {@link splitDescriptionByTechSteps} on its own. Never set on a * `isKeyword: true` segment (a temperature never falls inside a * technique's own keyword span). `StepDescription.tsx` renders it with * its own highlight + a Tooltip showing the normalized value. */ temperature?: StepTechStepTemperatureView | null; } /** * Splits `description` into an ordered sequence of plain/context/keyword * {@link DescriptionSegment}s using each `techSteps` entry's `start`/`end` * (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, 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 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.start - b.start); // 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 ( start < keywordCursor || wideStart > start || start >= end || end > wideEnd || wideEnd > description.length ) { 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({ text: description.slice(cursor, wideStart), techStep: null, isKeyword: false, source: null, }); } if (start > wideStart) { segments.push({ text: description.slice(wideStart, start), techStep, isKeyword: false, source, }); } segments.push({ text: description.slice(start, end), techStep, isKeyword: true, source }); if (wideEnd > end) { segments.push({ text: description.slice(end, wideEnd), techStep, isKeyword: false, source, }); } cursor = wideEnd; } if (cursor < description.length) { segments.push({ text: description.slice(cursor), techStep: null, isKeyword: false, source: null, }); } return segments; } /** * {@link splitDescriptionByTechSteps} plus a second, non-destructive pass * that splits every *non-keyword* run (plain text and technique-context * text alike) around any detected temperature span (`StepTechStepView.temperatures`, * flattened across every entry). A temperature run carries * `temperature` set and `techStep: null`. * * Kept separate from the technique split rather than folded into it: the * technique keyword/context overlap logic above is intricate and * well-covered by tests, and a temperature never competes with a * technique's own keyword span for the same characters ("enfourner" vs * "180 °C") — so layering temperatures on top of the finished segments is * both simpler and safer. Temperature spans are expected non-overlapping * and in order (the service emits them that way); one whose bounds don't * fall cleanly inside a single non-keyword run is skipped rather than * risking a garbled slice. */ export function splitDescriptionSegments( description: string, techSteps: StepTechStepView[], temperatures?: StepTechStepTemperatureView[], ): DescriptionSegment[] { const base = splitDescriptionByTechSteps(description, techSteps); const spans = temperatures ?? []; if (spans.length === 0) { return base.map((segment) => ({ ...segment, temperature: null })); } const sorted = [...spans] .filter((temp) => temp.start >= 0 && temp.start < temp.end && temp.end <= description.length) .sort((a, b) => a.start - b.start); const out: DescriptionSegment[] = []; let offset = 0; for (const segment of base) { const segStart = offset; const segEnd = offset + segment.text.length; offset = segEnd; // Keyword highlights and empty runs are never split. if (segment.isKeyword || segment.text.length === 0) { out.push({ ...segment, temperature: null }); continue; } const inside = sorted.filter((temp) => temp.start >= segStart && temp.end <= segEnd); if (inside.length === 0) { out.push({ ...segment, temperature: null }); continue; } let localCursor = segStart; for (const temp of inside) { if (temp.start < localCursor) continue; // overlapping — skip defensively if (temp.start > localCursor) { out.push({ text: description.slice(localCursor, temp.start), techStep: segment.techStep, isKeyword: false, source: segment.source, temperature: null, }); } out.push({ text: description.slice(temp.start, temp.end), techStep: null, isKeyword: false, source: null, temperature: temp, }); localCursor = temp.end; } if (localCursor < segEnd) { out.push({ text: description.slice(localCursor, segEnd), techStep: segment.techStep, isKeyword: false, source: segment.source, temperature: null, }); } } return out; }