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>
305 lines
13 KiB
TypeScript
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}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|