batchCooking/apps/web/src/features/recipes/steps/StepDescription.tsx
Nicolas 189df73b4c
Some checks failed
CI / lint (push) Successful in 2m33s
CI / intent-service-test (push) Failing after 31s
CI / build (push) Successful in 3m30s
CI / e2e (push) Successful in 11m14s
CI / test (push) Failing after 24m39s
feat(tech-steps): detecte les temperatures dans les etapes
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>
2026-08-29 19:29:03 +02:00

305 lines
13 KiB
TypeScript

import type {
StepTechStepTemperatureView,
StepTechStepView,
SubmitTechStepCorrectionResult,
} from "@batch-cooking/shared";
import { Fragment, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "../../../components/ui/Tooltip";
import { splitDescriptionSegments } from "./highlight-tech-steps";
import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
/**
* Human label for a detected temperature — the °C value, else the gas-mark
* number, else the qualitative level, each via i18n. Shown in the
* highlight's Tooltip (`raw` is what stays inline as the highlighted text).
* `t` is passed in (see `ingredient-label.ts` for the same "plain function,
* unit-testable" split).
*/
function temperatureLabel(
temperature: StepTechStepTemperatureView,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
if (temperature.celsius !== null) {
return t("recipes.temperature.celsius", { value: temperature.celsius });
}
if (temperature.gasMark !== null) {
return t("recipes.temperature.gasMark", { value: temperature.gasMark });
}
if (temperature.qualitative !== null) {
return t(`recipes.temperature.qualitative.${temperature.qualitative}`);
}
return temperature.raw;
}
/**
* A recipe step's description, with every detected technique's exact
* matched words highlighted and given a {@link Tooltip} naming the
* technique (e.g. hovering/focusing "hacher" in "Hacher les oignons" shows
* "Hacher") — `RecipeDetailPanel`'s replacement for a bare `<p>{description}</p>`.
*
* A match's wider `contextStart`/`contextEnd` clause (see
* `StepTechStepView`) is deliberately *not* visualized here — only the
* tight keyword span is highlighted. The backend still computes and
* persists it (`tech-step-matcher.ts`/`StepTechStep`), and
* `splitDescriptionByTechSteps` still splits the description around it
* (`isKeyword: false` context segments), but this component now renders
* those non-keyword segments as plain text, same as a segment with no
* technique at all — the visual "wider clause, subtler highlight"
* treatment (`.step-tech-step-context`) turned out to be more visual noise
* than useful signal in practice and was turned back off.
*
* `techStep.key` resolves its tooltip label through `catalog.techSteps.<key>`
* i18n, the same pattern every other reference catalog (diets, units, …)
* uses for its display text. A keyword's `source` (`"auto"` — the
* classifier — vs `"manual"` — a viewer's correction, applied immediately)
* gets its own modifier class (`.step-tech-step--manual`), a different
* color, so the two are visually distinguishable at a glance rather than
* only via the tooltip text.
*
* `editable` (off by default) additionally lets the viewer select text or
* click an existing highlight to open a {@link TechStepCorrectionPopover} —
* see `use-text-selection.ts` for how a browser selection is translated
* into an absolute `[start, end)` span. When `!editable`, every segment
* renders exactly as before (no extra wrapping elements, no `data-offset`,
* no click handlers) — this mode is purely additive, not a rewrite of the
* read-only rendering.
*
* Maintains its own local copy of `techSteps` (seeded from the prop, then
* replaced with whatever `POST .../corrections` returns on a successful
* submit — see `SubmitTechStepCorrectionResult`'s doc comment,
* `packages/shared`) so a correction's effect (a new/relabeled/removed
* highlight) appears immediately, without needing the parent to re-fetch
* the whole recipe. Resynced whenever the `techSteps` prop itself changes
* (e.g. the parent reloaded the recipe for an unrelated reason) so this
* never keeps showing stale local state past that.
*/
export function StepDescription({
description,
techSteps,
editable = false,
recipeId,
stepId,
}: {
description: string;
techSteps: StepTechStepView[];
/** Requires `recipeId`/`stepId` when `true` — omit (or leave `false`) for a read-only view with nothing real to correct against yet (e.g. `RecipeDetailPanel`'s `"loaded-draft"` unsaved-preview branch). */
editable?: boolean;
recipeId?: number;
stepId?: number;
}) {
const { t } = useTranslation();
const [liveTechSteps, setLiveTechSteps] = useState(techSteps);
useEffect(() => setLiveTechSteps(techSteps), [techSteps]);
// Temperatures are attached per technique-clause server-side; for the
// read-only highlight we only care *where* in the description they are,
// so flatten them across every entry (deduped by span). `?? []` guards a
// pre-`temperatures` payload the same way this component already tolerates
// a match with no `contextStart`/`contextEnd`.
const temperatures = Array.from(
new Map(
liveTechSteps
.flatMap((techStep) => techStep.temperatures ?? [])
.map((temperature) => [`${temperature.start}:${temperature.end}`, temperature]),
).values(),
);
const segments = splitDescriptionSegments(description, liveTechSteps, temperatures);
const containerRef = useRef<HTMLParagraphElement>(null);
const { getSelectionRange } = useTextSelection(containerRef);
const [activeCorrection, setActiveCorrection] = useState<{
range: TextSelectionRange;
selectedText: string;
previousTechStepId: number | null;
} | null>(null);
// Routes the *next* text selection to the open `TechStepCorrectionPopover`
// (as an ingredient/utensil mention span) instead of opening a brand-new
// correction — set when that popover calls `onRequestSpan`, cleared once
// `handleMouseUp` resolves the selection below. See
// `TechStepCorrectionPopover.tsx`'s own doc comment for why this can live
// entirely alongside the still-visible, still-selectable description
// rather than needing the popover itself to move/hide.
const [pendingSpanRequest, setPendingSpanRequest] = useState<"ingredient" | "utensil" | null>(
null,
);
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
nonce: number;
kind: "ingredient" | "utensil";
range: TextSelectionRange;
text: string;
} | null>(null);
const nextMetadataSpanNonce = useRef(0);
function closeActiveCorrection() {
setActiveCorrection(null);
setPendingSpanRequest(null);
setResolvedMetadataSpan(null);
}
function handleMouseUp() {
if (!editable) return;
const range = getSelectionRange();
if (!range) return;
if (pendingSpanRequest !== null) {
nextMetadataSpanNonce.current += 1;
setResolvedMetadataSpan({
nonce: nextMetadataSpanNonce.current,
kind: pendingSpanRequest,
range,
text: description.slice(range.start, range.end),
});
setPendingSpanRequest(null);
return;
}
setActiveCorrection({
range,
selectedText: description.slice(range.start, range.end),
previousTechStepId: null,
});
}
function handleSubmitted(result: SubmitTechStepCorrectionResult) {
setLiveTechSteps(result.techSteps);
}
// The occurrence `activeCorrection` is currently open for, matched by its
// exact `[start, end)` (not just `techStep.id` — the same technique can
// legitimately occur more than once in one description) — whatever
// ingredients/utensils it already carries seed
// `TechStepCorrectionPopover`'s own pending lists. `undefined` (not an
// empty array) for a brand-new selection, same as "nothing to look up
// yet".
const activeStepTechStep = activeCorrection
? liveTechSteps.find(
(techStep) =>
techStep.start === activeCorrection.range.start &&
techStep.end === activeCorrection.range.end,
)
: undefined;
// Tracks each segment's own absolute start offset into `description` as
// the map below walks them in order — segments are contiguous and cover
// the whole description (see `splitDescriptionByTechSteps`'s doc
// comment), so a running total is exact, no re-derivation needed.
let offset = 0;
return (
<>
<p ref={containerRef} onMouseUp={handleMouseUp}>
{segments.map((segment, index) => {
const start = offset;
offset += segment.text.length;
// Captured now, not read as `offset` later inside a click
// handler below — `offset` keeps mutating for every subsequent
// segment this same `.map()` pass renders, so a closure
// referencing it directly would see its *final* value (the end
// of the whole description) whenever it actually fires, long
// after render — found via a real correction submitted with
// `end` far past this segment's own text.
const end = offset;
// A segment's own text/techStep don't uniquely identify it (the
// same word can appear twice in one description) — index is the
// only thing that does, but this list is fully regenerated from
// `description`/`liveTechSteps` on every render (never reordered
// or spliced in place), so using it as part of the key is safe
// here.
const key = `${index}-${segment.text}`;
if (segment.temperature) {
// A detected temperature mention ("180°C", "feu doux") — its
// own subtle highlight + a Tooltip with the normalized value.
// Not correctable (no popover), same as utensils; still gets a
// `data-offset` in editable mode so a text selection spanning
// it resolves correctly.
const label = temperatureLabel(segment.temperature, t);
const modifier = segment.temperature.qualitative ?? "value";
return (
<Tooltip key={key} content={label}>
<span
className={`step-temperature step-temperature--${modifier}`}
data-offset={editable ? start : undefined}
>
{segment.text}
</span>
</Tooltip>
);
}
if (!segment.techStep || !segment.isKeyword) {
// Context-only or plain run — rendered as plain text in
// read-only mode, same as before this component supported
// `editable` at all (see this component's doc comment for why
// the wider-clause highlight itself was turned back off).
// Editable mode still wraps it in a `data-offset` span so a
// selection starting/ending in plain text resolves correctly.
if (!editable) return <Fragment key={key}>{segment.text}</Fragment>;
return (
<span key={key} data-offset={start}>
{segment.text}
</span>
);
}
const techStep = segment.techStep;
const isManual = segment.source === "manual";
const tooltipLabel = isManual
? t("recipes.techStepCorrection.manualTooltip", {
technique: t(`catalog.techSteps.${techStep.key}`),
})
: t(`catalog.techSteps.${techStep.key}`);
return (
<Tooltip key={key} content={tooltipLabel}>
{/* A real <button>, not a <mark>, so it's natively focusable
(keyboard/screen-reader users can reach the tooltip) without
fighting the "non-interactive element" a11y lint a bare
tabIndex on <mark> would trip — styled to read as inline
highlighted text, not as a button (see .step-tech-step). */}
<button
type="button"
className={isManual ? "step-tech-step step-tech-step--manual" : "step-tech-step"}
data-offset={editable ? start : undefined}
onClick={
editable
? () => {
// Clears any in-progress ingredient/utensil
// span-selection from whatever correction was open
// before — opening a *different* one has nothing
// left to resolve that selection into.
setPendingSpanRequest(null);
setResolvedMetadataSpan(null);
setActiveCorrection({
range: { start, end },
selectedText: segment.text,
previousTechStepId: techStep.id,
});
}
: undefined
}
>
{segment.text}
</button>
</Tooltip>
);
})}
</p>
{editable && activeCorrection && recipeId !== undefined && stepId !== undefined && (
<TechStepCorrectionPopover
recipeId={recipeId}
stepId={stepId}
range={activeCorrection.range}
selectedText={activeCorrection.selectedText}
previousTechStepId={activeCorrection.previousTechStepId}
existingIngredients={activeStepTechStep?.ingredients ?? []}
existingUtensils={activeStepTechStep?.utensils ?? []}
resolvedMetadataSpan={resolvedMetadataSpan}
onRequestSpan={setPendingSpanRequest}
onClose={closeActiveCorrection}
onSubmitted={handleSubmitted}
/>
)}
</>
);
}