3e type de metadonnee de clause, a cote des ingredients et des ustensiles : temperature en °C (« 180°C », « 200 degres », °F converti), numero de thermostat (« th. 6 ») et intensite qualitative (« feu doux/moyen/vif » -> low/medium/high). services/tech-step-intent-service : - `temperature_extraction.py` : `extract_temperatures(text, locale)` pur, a base de regex (independant du modele spaCy et de l'entrainement). - `ProcessResult` / `ProcessResponse` gagnent `temperatures` (ajout additif au contrat ; cle `gas_mark` en snake_case sur le fil). `process()` les extrait meme pour une locale pas encore entrainee. - Tests `test_temperature_extraction.py` (10) ; `test_routes_process.py` : 2 assertions d'egalite stricte gagnent `"temperatures": []`, +1 cas. apps/api : - `IntentServiceTemperature` (mappe `gas_mark` -> `gasMark` a la frontiere). - `TechStepMatch.temperatures` : filtrees par appartenance de span a la clause, meme regle que les ustensiles. - `model StepTechStepTemperature` (aucune FK — la valeur structuree EST la donnee) + migration manuelle `20260829120000_step_tech_step_temperature` (cascade via le TRUNCATE de `step_tech_step`, rien a ajouter a reset-db.ts). Persistance + lecture dans `recipe.service.ts` (`recipeInclude`, `toStepTechStepViews`) et l'include de sequence fraiche du service de correction ; `sources.service.ts` (apercu d'import) les fait transiter. packages/shared : `StepTechStepTemperatureView` + `temperatures` sur `StepTechStepView`. apps/web : - `splitDescriptionSegments` enrobe `splitDescriptionByTechSteps` et redecoupe les segments non-keyword autour des spans de temperature (la logique technique intriquee reste intacte). `?? []` tolere une payload d'avant `temperatures`. - `StepDescription` : surlignage `.step-temperature` + Tooltip via `temperatureLabel` ; i18n `recipes.temperature.*`. - Tests composants +4 (22 verts) ; pas d'UI de correction (v1, comme les ustensiles). Verifie : biome + tsc + `pnpm -r build` ; web 102/103 e2e (l'echec est le flake pre-existant recipe-form.feature, sans rapport) + 49/49 composants ; pytest temperature + routes 15/15. `pnpm --filter api test` (Postgres + intent-service requis) non lance ici. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
235 lines
9.6 KiB
TypeScript
235 lines
9.6 KiB
TypeScript
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;
|
|
}
|