feat(tech-steps): detecte les temperatures dans les etapes
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

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>
This commit is contained in:
Nicolas 2026-08-29 17:38:02 +02:00
parent 1a28d67218
commit 189df73b4c
20 changed files with 837 additions and 25 deletions

View file

@ -0,0 +1,22 @@
-- CreateTable: temperatures mentioned in the same clause as a detected
-- technique — regex-extracted (services/tech-step-intent-service), no
-- foreign key of its own (the structured value is the data). Same
-- composite FK + ON DELETE CASCADE shape as step_tech_step_ingredient /
-- step_tech_step_utensil (see 20260826121000_step_tech_step_metadata).
CREATE TABLE "step_tech_step_temperature" (
"id" SERIAL NOT NULL,
"step_id" INTEGER NOT NULL,
"tech_step_order" INTEGER NOT NULL,
"start" INTEGER NOT NULL,
"end" INTEGER NOT NULL,
"celsius" DECIMAL(6,2),
"gas_mark" INTEGER,
"qualitative" TEXT,
"raw" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'auto',
CONSTRAINT "step_tech_step_temperature_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "step_tech_step_temperature" ADD CONSTRAINT "step_tech_step_temperature_step_id_tech_step_order_fkey" FOREIGN KEY ("step_id", "tech_step_order") REFERENCES "step_tech_step"("step_id", "order") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -810,6 +810,9 @@ model StepTechStep {
/// Utensils mentioned in the same clause as this technique occurrence — /// Utensils mentioned in the same clause as this technique occurrence —
/// see `StepTechStepUtensil`. /// see `StepTechStepUtensil`.
utensils StepTechStepUtensil[] utensils StepTechStepUtensil[]
/// Temperatures mentioned in the same clause as this technique occurrence
/// — see `StepTechStepTemperature`.
temperatures StepTechStepTemperature[]
@@id([stepId, order]) @@id([stepId, order])
@@map("step_tech_step") @@map("step_tech_step")
@ -868,6 +871,38 @@ model StepTechStepUtensil {
@@map("step_tech_step_utensil") @@map("step_tech_step_utensil")
} }
/// A temperature mention found in the same *clause* as one `StepTechStep`
/// occurrence — same association rule as `StepTechStepIngredient` /
/// `StepTechStepUtensil` (see their doc comments). Detected by
/// `services/tech-step-intent-service`'s regex pass (`temperature_extraction.py`),
/// not a `PhraseMatcher` — a temperature is numeric/pattern, not a word from
/// a finite vocabulary. Carries no foreign key: the structured value *is*
/// the data. At least one of `celsius` / `gasMark` / `qualitative` is
/// non-null:
/// - `celsius` — an oven/pan temperature in °C (a °F mention is
/// converted + rounded upstream).
/// - `gasMark` — a gas-mark / thermostat number ("th. 6").
/// - `qualitative` — "low" | "medium" | "high" ("feu doux/moyen/vif").
/// `raw` is the exact recognized fragment, `start`/`end` its span in
/// `Step.description` ([start, end), same as `StepTechStep.start`/`end`).
/// `source` mirrors the others (`"auto"` only today — no correction UI).
model StepTechStepTemperature {
id Int @id @default(autoincrement())
stepId Int @map("step_id")
techStepOrder Int @map("tech_step_order")
start Int
end Int
celsius Decimal? @db.Decimal(6, 2)
gasMark Int? @map("gas_mark")
qualitative String?
raw String
source String @default("auto")
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
@@map("step_tech_step_temperature")
}
/// One user-submitted correction to a `Step`'s detected techniques — /// One user-submitted correction to a `Step`'s detected techniques —
/// captures ADD (a missing technique the classifier didn't find), /// captures ADD (a missing technique the classifier didn't find),
/// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape: /// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape:

View file

@ -25,11 +25,28 @@ export interface IntentServiceEntity {
kind: "technique" | "utensil"; kind: "technique" | "utensil";
} }
/** The full result of a `POST /v1/process` call — mirrors `ProcessResponse` (Python `schemas.py`). `intent` is `null` only when `locale` isn't one this service trains for, or `text` is blank; otherwise always a real `uid` (the Python service's `textcat` has no "None" sentinel, unlike node-nlp — see that service's README). */ /**
* A temperature the service recognized in the text mirrors
* `TemperaturePayload` (Python `schemas.py`), `gas_mark` camelCased to
* `gasMark`. A separate list from {@link IntentServiceEntity} (not another
* `kind`) because it carries structured fields that don't fit an entity's
* `uid`. At least one of `celsius` / `gasMark` / `qualitative` is non-null.
*/
export interface IntentServiceTemperature {
start: number;
end: number;
celsius: number | null;
gasMark: number | null;
qualitative: "low" | "medium" | "high" | null;
raw: string;
}
/** The full result of a `POST /v1/process` call — mirrors `ProcessResponse` (Python `schemas.py`). `intent` is `null` only when `locale` isn't one this service trains for, or `text` is blank; otherwise always a real `uid` (the Python service's `textcat` has no "None" sentinel, unlike node-nlp — see that service's README). `temperatures` is regex-extracted and independent of the model/training (empty when none, not omitted). */
export interface IntentServiceProcessResult { export interface IntentServiceProcessResult {
entities: IntentServiceEntity[]; entities: IntentServiceEntity[];
intent: string | null; intent: string | null;
score: number; score: number;
temperatures: IntentServiceTemperature[];
} }
/** /**
@ -88,10 +105,26 @@ export class IntentServiceClient {
*/ */
public async process(locale: string, text: string): Promise<IntentServiceProcessResult> { public async process(locale: string, text: string): Promise<IntentServiceProcessResult> {
try { try {
return await this._request("/v1/process", { // The wire shape uses `gas_mark` (Python field name); everything else
// is already camelCase / single-word. Map that one key at the
// boundary so the rest of `apps/api` only ever sees `gasMark`.
const wire = await this._request<
Omit<IntentServiceProcessResult, "temperatures"> & {
temperatures: (Omit<IntentServiceTemperature, "gasMark"> & { gas_mark: number | null })[];
}
>("/v1/process", {
method: "POST", method: "POST",
body: JSON.stringify({ locale, text }), body: JSON.stringify({ locale, text }),
}); });
return {
entities: wire.entities,
intent: wire.intent,
score: wire.score,
temperatures: (wire.temperatures ?? []).map(({ gas_mark, ...rest }) => ({
...rest,
gasMark: gas_mark,
})),
};
} catch (err) { } catch (err) {
throw err; throw err;
} }

View file

@ -5,7 +5,7 @@ import {
loadIngredientCatalog, loadIngredientCatalog,
loadUnitCatalog, loadUnitCatalog,
} from "./ingredient-matcher.js"; } from "./ingredient-matcher.js";
import { intentServiceClient } from "./intent-service-client.js"; import { type IntentServiceTemperature, intentServiceClient } from "./intent-service-client.js";
/** /**
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe * Auto-detects which cooking techniques (`TechStep`) a free-text recipe
@ -114,6 +114,7 @@ export interface TechStepMatch {
contextEnd: number; contextEnd: number;
ingredients: IngredientMention[]; ingredients: IngredientMention[];
utensils: UtensilMention[]; utensils: UtensilMention[];
temperatures: TemperatureMention[];
} }
/** /**
@ -129,6 +130,15 @@ export interface UtensilMention {
end: number; end: number;
} }
/**
* A temperature the intent service's regex pass found (`temperatures` in
* `IntentServiceProcessResult`), attributed to whichever clause its span
* falls inside same rule as {@link UtensilMention}. No DB id to resolve
* (unlike a technique/ingredient/utensil): the structured value *is* the
* data. Persisted verbatim as a `StepTechStepTemperature` row.
*/
export type TemperatureMention = IntentServiceTemperature;
/** A candidate technique mention found by NER — the raw material {@link splitIntoClauses} cuts a description around. */ /** A candidate technique mention found by NER — the raw material {@link splitIntoClauses} cuts a description around. */
export interface TechniqueCandidate { export interface TechniqueCandidate {
/** Training-data `uid` this candidate's synonym belongs to (e.g. `"melt"`) — not yet resolved to a DB id at this stage. */ /** Training-data `uid` this candidate's synonym belongs to (e.g. `"melt"`) — not yet resolved to a DB id at this stage. */
@ -408,6 +418,7 @@ export class TechStepClassifierService {
.filter((entity) => entity.kind === "technique") .filter((entity) => entity.kind === "technique")
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end })); .map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
const utensilEntities = nerResult.entities.filter((entity) => entity.kind === "utensil"); const utensilEntities = nerResult.entities.filter((entity) => entity.kind === "utensil");
const temperatureMentions = nerResult.temperatures;
const clauses = splitIntoClauses(description, candidates); const clauses = splitIntoClauses(description, candidates);
const matches: TechStepMatch[] = []; const matches: TechStepMatch[] = [];
@ -442,6 +453,12 @@ export class TechStepClassifierService {
: [{ utensilId, start: entity.start, end: entity.end }]; : [{ utensilId, start: entity.start, end: entity.end }];
}); });
// Temperatures carry no id to resolve — kept as-is, just attributed
// to the clause whose span contains them (same rule as utensils).
const temperatures: TemperatureMention[] = temperatureMentions.filter(
(temp) => temp.start >= clause.start && temp.end <= clause.end,
);
matches.push({ matches.push({
techStepId, techStepId,
start: span.start, start: span.start,
@ -450,6 +467,7 @@ export class TechStepClassifierService {
contextEnd: clause.end, contextEnd: clause.end,
ingredients, ingredients,
utensils, utensils,
temperatures,
}); });
} }

View file

@ -446,10 +446,11 @@ export async function submitTechStepCorrection(
}, },
); );
// Same nested `ingredients`/`utensils` include as `recipe.service.ts`'s // Same nested `ingredients`/`utensils`/`temperatures` include as
// `recipeInclude` — `toStepTechStepViews` (reused below) expects it, // `recipe.service.ts`'s `recipeInclude` — `toStepTechStepViews`
// so the fresh sequence read right after a manual correction resolves // (reused below) expects it, so the fresh sequence read right after a
// exactly the same way a normal `GET /recipes/:id` would. // manual correction resolves exactly the same way a normal
// `GET /recipes/:id` would.
const freshTechSteps = await tx.stepTechStep.findMany({ const freshTechSteps = await tx.stepTechStep.findMany({
where: { stepId: step.id }, where: { stepId: step.id },
orderBy: { order: "asc" }, orderBy: { order: "asc" },
@ -467,6 +468,7 @@ export async function submitTechStepCorrection(
}, },
}, },
utensils: { include: { utensil: true } }, utensils: { include: { utensil: true } },
temperatures: true,
}, },
}); });

View file

@ -68,6 +68,7 @@ function recipeInclude(viewerId: number) {
}, },
}, },
utensils: { include: { utensil: true } }, utensils: { include: { utensil: true } },
temperatures: true,
}, },
}, },
}, },
@ -175,8 +176,17 @@ export function toStepTechStepViews(
): StepTechStepView[] { ): StepTechStepView[] {
const views: StepTechStepView[] = []; const views: StepTechStepView[] = [];
for (const stepTechStep of techSteps) { for (const stepTechStep of techSteps) {
const { start, end, contextStart, contextEnd, techStep, source, ingredients, utensils } = const {
stepTechStep; start,
end,
contextStart,
contextEnd,
techStep,
source,
ingredients,
utensils,
temperatures,
} = stepTechStep;
if (start === null || end === null) continue; if (start === null || end === null) continue;
views.push({ views.push({
techStep: { id: techStep.id, key: techStep.key }, techStep: { id: techStep.id, key: techStep.key },
@ -206,6 +216,24 @@ export function toStepTechStepViews(
end: stepTechStepUtensil.end, end: stepTechStepUtensil.end,
source: stepTechStepUtensil.source === "manual" ? "manual" : "auto", source: stepTechStepUtensil.source === "manual" ? "manual" : "auto",
})), })),
temperatures: temperatures.map((stepTechStepTemperature) => ({
// `celsius` comes back as a Prisma `Decimal` — plain `number` here,
// same treatment as `StepTechStepIngredient.quantity` above.
celsius:
stepTechStepTemperature.celsius === null ? null : Number(stepTechStepTemperature.celsius),
gasMark: stepTechStepTemperature.gasMark,
qualitative:
stepTechStepTemperature.qualitative === "low" ||
stepTechStepTemperature.qualitative === "medium" ||
stepTechStepTemperature.qualitative === "high"
? stepTechStepTemperature.qualitative
: null,
raw: stepTechStepTemperature.raw,
start: stepTechStepTemperature.start,
end: stepTechStepTemperature.end,
// Same narrowing posture as the technique's own `source` above.
source: stepTechStepTemperature.source === "manual" ? "manual" : "auto",
})),
}); });
} }
return views; return views;
@ -690,6 +718,16 @@ async function createRecipeInternal(
end: utensil.end, end: utensil.end,
})), })),
}, },
temperatures: {
create: match.temperatures.map((temperature) => ({
start: temperature.start,
end: temperature.end,
celsius: temperature.celsius,
gasMark: temperature.gasMark,
qualitative: temperature.qualitative,
raw: temperature.raw,
})),
},
})), })),
}, },
})), })),

View file

@ -260,6 +260,12 @@ export async function previewSourceItem(
? [{ utensil, start: mention.start, end: mention.end, source: "auto" as const }] ? [{ utensil, start: mention.start, end: mention.end, source: "auto" as const }]
: []; : [];
}), }),
// Regex-extracted, no id to resolve — carried through as-is
// (same `source: "auto"` reasoning as this match above).
temperatures: match.temperatures.map((mention) => ({
...mention,
source: "auto" as const,
})),
}, },
] ]
: []; : [];

View file

@ -404,6 +404,42 @@ describe("Recipes", () => {
expect(description.slice(start, end).toLowerCase()).to.equal("mijoter"); expect(description.slice(start, end).toLowerCase()).to.equal("mijoter");
}); });
it("extracts a step's temperature mentions and exposes them on the technique clause", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const description = "Enfourner à 180°C puis finir à feu doux";
const res = await agent.post("/recipes").send({
name: "Gratin",
portions: 4,
dietIds: [],
ingredients: [{ ingredientId: tomate, quantity: 1, unitId: piece }],
steps: [{ description }],
});
expect(res.status).to.equal(201);
const temperatures = res.body.steps[0].techSteps.flatMap(
(techStep: { temperatures: unknown[] }) => techStep.temperatures,
);
// "180°C" -> a celsius value; "feu doux" -> qualitative "low".
const celsius = temperatures.find(
(temperature: { celsius: number | null }) => temperature.celsius !== null,
);
expect(celsius).to.include({
celsius: 180,
gasMark: null,
qualitative: null,
source: "auto",
});
expect(description.slice(celsius.start, celsius.end)).to.equal("180°C");
expect(
temperatures.some(
(temperature: { qualitative: string | null }) => temperature.qualitative === "low",
),
).to.equal(true);
});
it("leaves a step's technique sequence empty when its description matches no known technique", async () => { it("leaves a step's technique sequence empty when its description matches no known technique", async () => {
const { agent } = await signup(); const { agent } = await signup();
const tomate = await ingredientId("tomato"); const tomate = await ingredientId("tomato");

View file

@ -1,5 +1,8 @@
import type { StepTechStepView } from "@batch-cooking/shared"; import type { StepTechStepTemperatureView, StepTechStepView } from "@batch-cooking/shared";
import { splitDescriptionByTechSteps } from "../../src/features/recipes/steps/highlight-tech-steps"; import {
splitDescriptionByTechSteps,
splitDescriptionSegments,
} from "../../src/features/recipes/steps/highlight-tech-steps";
// Pure logic, no DOM/mount needed — reuses the component-test runner // Pure logic, no DOM/mount needed — reuses the component-test runner
// (Cypress's Mocha/Chai, same as CheckboxOption.cy.tsx) purely for its // (Cypress's Mocha/Chai, same as CheckboxOption.cy.tsx) purely for its
@ -248,3 +251,82 @@ describe("splitDescriptionByTechSteps", () => {
}); });
}); });
}); });
/** Builds a `StepTechStepTemperatureView` — only the fields a test cares about, the rest nulled. */
function temperature(
start: number,
end: number,
fields: Partial<StepTechStepTemperatureView> = {},
): StepTechStepTemperatureView {
return {
start,
end,
celsius: null,
gasMark: null,
qualitative: null,
raw: "",
source: "auto",
...fields,
};
}
describe("splitDescriptionSegments", () => {
it("adds temperature: null to every segment when there are no temperatures", () => {
const result = splitDescriptionSegments("Hacher les oignons", [techStep("chop", 1, 0, 6)], []);
expect(result).to.deep.equal([
{
text: "Hacher",
techStep: { id: 1, key: "chop" },
isKeyword: true,
source: "auto",
temperature: null,
},
{ text: " les oignons", techStep: null, isKeyword: false, source: null, temperature: null },
]);
});
it("splits a plain run around a temperature mention, keeping the technique keyword untouched", () => {
const text = "Enfourner à 180°C environ 30 min";
const temp = temperature(12, 17, { celsius: 180, raw: "180°C" });
const result = splitDescriptionSegments(text, [techStep("bake", 2, 0, 9)], [temp]);
expect(result).to.deep.equal([
{
text: "Enfourner",
techStep: { id: 2, key: "bake" },
isKeyword: true,
source: "auto",
temperature: null,
},
{ text: " à ", techStep: null, isKeyword: false, source: null, temperature: null },
{ text: "180°C", techStep: null, isKeyword: false, source: null, temperature: temp },
{
text: " environ 30 min",
techStep: null,
isKeyword: false,
source: null,
temperature: null,
},
]);
});
it("splits around two temperatures in one description", () => {
const text = "Saisir à feu vif puis 180°C";
const vif = temperature(9, 16, { qualitative: "high", raw: "feu vif" });
const celsius = temperature(22, 27, { celsius: 180, raw: "180°C" });
const result = splitDescriptionSegments(text, [], [vif, celsius]);
expect(result.filter((segment) => segment.temperature !== null)).to.deep.equal([
{ text: "feu vif", techStep: null, isKeyword: false, source: null, temperature: vif },
{ text: "180°C", techStep: null, isKeyword: false, source: null, temperature: celsius },
]);
});
it("skips a temperature span that isn't fully inside a single non-keyword run", () => {
// Span straddles the keyword — dropped rather than producing a garbled slice.
const result = splitDescriptionSegments(
"Cuire au four",
[techStep("bake", 1, 0, 5)],
[temperature(3, 10)],
);
expect(result.some((segment) => segment.temperature !== null)).to.equal(false);
});
});

View file

@ -696,6 +696,34 @@
} }
} }
// A detected temperature mention ("180 °C", "th. 6", "feu doux") same
// inline "highlighted reading text" language as `.step-tech-step`, but in
// `--color-accent` (Vermillion, the "heat" accent) rather than
// `--color-primary`, so a temperature reads as its own kind of metadata at
// a glance. `--low`/`--medium`/`--high` only vary the underline weight
// the qualitative scale is a nuance, not three different colors.
.step-temperature {
padding: 0 0.15em;
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
border-radius: 0.2em;
text-decoration: underline dotted var(--color-accent);
text-decoration-thickness: 1px;
text-underline-offset: 0.15em;
cursor: help;
&:hover {
background: color-mix(in srgb, var(--color-accent) 22%, transparent);
}
&--high {
text-decoration-thickness: 2px;
}
&--low {
text-decoration-style: dashed;
}
}
// `.step-tech-step-context` (the wider clause a `.step-tech-step` keyword // `.step-tech-step-context` (the wider clause a `.step-tech-step` keyword
// was found in) used to be highlighted here too, more subtly turned back // was found in) used to be highlighted here too, more subtly turned back
// off (see `StepDescription.tsx`'s doc comment): the backend still // off (see `StepDescription.tsx`'s doc comment): the backend still

View file

@ -1,11 +1,38 @@
import type { StepTechStepView, SubmitTechStepCorrectionResult } from "@batch-cooking/shared"; import type {
StepTechStepTemperatureView,
StepTechStepView,
SubmitTechStepCorrectionResult,
} from "@batch-cooking/shared";
import { Fragment, useEffect, useRef, useState } from "react"; import { Fragment, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Tooltip } from "../../../components/ui/Tooltip"; import { Tooltip } from "../../../components/ui/Tooltip";
import { splitDescriptionByTechSteps } from "./highlight-tech-steps"; import { splitDescriptionSegments } from "./highlight-tech-steps";
import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover"; import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
import { type TextSelectionRange, useTextSelection } from "./use-text-selection"; 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 * A recipe step's description, with every detected technique's exact
* matched words highlighted and given a {@link Tooltip} naming the * matched words highlighted and given a {@link Tooltip} naming the
@ -66,7 +93,19 @@ export function StepDescription({
const [liveTechSteps, setLiveTechSteps] = useState(techSteps); const [liveTechSteps, setLiveTechSteps] = useState(techSteps);
useEffect(() => setLiveTechSteps(techSteps), [techSteps]); useEffect(() => setLiveTechSteps(techSteps), [techSteps]);
const segments = splitDescriptionByTechSteps(description, liveTechSteps); // 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 containerRef = useRef<HTMLParagraphElement>(null);
const { getSelectionRange } = useTextSelection(containerRef); const { getSelectionRange } = useTextSelection(containerRef);
@ -169,6 +208,26 @@ export function StepDescription({
// here. // here.
const key = `${index}-${segment.text}`; 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) { if (!segment.techStep || !segment.isKeyword) {
// Context-only or plain run — rendered as plain text in // Context-only or plain run — rendered as plain text in
// read-only mode, same as before this component supported // read-only mode, same as before this component supported

View file

@ -1,4 +1,4 @@
import type { StepTechStepView } from "@batch-cooking/shared"; import type { StepTechStepTemperatureView, StepTechStepView } from "@batch-cooking/shared";
/** /**
* One run of a step's `description` either plain text, or part of a * One run of a step's `description` either plain text, or part of a
@ -20,6 +20,15 @@ export interface DescriptionSegment {
isKeyword: boolean; 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. */ /** 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; 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;
} }
/** /**
@ -139,3 +148,88 @@ export function splitDescriptionByTechSteps(
} }
return segments; 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;
}

View file

@ -166,6 +166,15 @@
"cancelDeleteButton": "Annuler", "cancelDeleteButton": "Annuler",
"ingredientsTitle": "Ingrédients", "ingredientsTitle": "Ingrédients",
"stepsTitle": "Préparation", "stepsTitle": "Préparation",
"temperature": {
"celsius": "{{value}} °C",
"gasMark": "Thermostat {{value}}",
"qualitative": {
"low": "Feu doux",
"medium": "Feu moyen",
"high": "Feu vif"
}
},
"techStepCorrection": { "techStepCorrection": {
"selectionLabel": "« {{text}} »", "selectionLabel": "« {{text}} »",
"removeMatch": "Aucune technique ici", "removeMatch": "Aucune technique ici",

View file

@ -57,10 +57,11 @@ export interface RecipeIngredientView {
* `applyManualCorrection`). `StepDescription.tsx` renders the two with a * `applyManualCorrection`). `StepDescription.tsx` renders the two with a
* different highlight color so a viewer can tell which is which. * different highlight color so a viewer can tell which is which.
* *
* `ingredients`/`utensils` are the metadata found in this technique's own * `ingredients`/`utensils`/`temperatures` are the metadata found in this
* clause (see `tech-step-matcher.ts`'s `TechStepMatch` same source data, * technique's own clause (see `tech-step-matcher.ts`'s `TechStepMatch`
* just resolved to full reference views here instead of bare ids) `[]` * same source data, just resolved to full reference views here instead of
* when nothing was mentioned alongside this technique. * bare ids) `[]` when nothing of that kind was mentioned alongside this
* technique.
*/ */
export interface StepTechStepView { export interface StepTechStepView {
techStep: TechStepView; techStep: TechStepView;
@ -71,6 +72,36 @@ export interface StepTechStepView {
source: "auto" | "manual"; source: "auto" | "manual";
ingredients: StepTechStepIngredientView[]; ingredients: StepTechStepIngredientView[];
utensils: StepTechStepUtensilView[]; utensils: StepTechStepUtensilView[];
temperatures: StepTechStepTemperatureView[];
}
/**
* A temperature mentioned in the same clause as a detected technique (see
* {@link StepTechStepView.temperatures}) extracted by
* `services/tech-step-intent-service`'s regex pass, not a `PhraseMatcher`
* (a temperature is numeric/pattern, not a word from a finite vocabulary).
* At least one of `celsius` / `gasMark` / `qualitative` is non-null:
*
* - `celsius` an oven/pan temperature in °C ("180 °C", "200 degrés",
* "240°"). A Fahrenheit mention ("350 °F") is converted and rounded.
* - `gasMark` a gas-mark / thermostat number ("th. 6", "thermostat 7").
* - `qualitative` a qualitative heat level, normalized to
* `"low" | "medium" | "high"` ("feu doux/moyen/vif", "high heat").
*
* `raw` is the exact recognized fragment, for display. `start`/`end` are its
* span in the step's `description`, same `[start, end)` convention as
* {@link StepTechStepView.start}. `source` mirrors
* {@link StepTechStepView.source} always `"auto"` today (no correction UI
* for temperatures yet, same as utensils).
*/
export interface StepTechStepTemperatureView {
celsius: number | null;
gasMark: number | null;
qualitative: "low" | "medium" | "high" | null;
raw: string;
start: number;
end: number;
source: "auto" | "manual";
} }
/** /**

View file

@ -31,6 +31,7 @@ from spacy.training import Example
from spacy.util import filter_spans, fix_random_seed, minibatch from spacy.util import filter_spans, fix_random_seed, minibatch
from . import utensil_vocabulary from . import utensil_vocabulary
from .temperature_extraction import Temperature, extract_temperatures
from .text_normalization import normalize_text from .text_normalization import normalize_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -190,6 +191,9 @@ class ProcessResult:
entities: list[Entity] entities: list[Entity]
intent: str | None intent: str | None
score: float score: float
# Températures détectées dans `text` (regex, indépendantes du modèle et
# de l'entraînement) — voir `temperature_extraction.py`.
temperatures: list[Temperature] = field(default_factory=list)
class UnsupportedLocaleError(ValueError): class UnsupportedLocaleError(ValueError):
@ -415,8 +419,15 @@ class LocalePipeline:
textcat n'a alors jamais été construit — voir son propre textcat n'a alors jamais été construit — voir son propre
commentaire). commentaire).
""" """
# Indépendant du modèle et de l'entraînement — une regex sur le
# texte brut, donc extraite même pour une locale pas encore
# entraînée (elle ne sera juste jamais accompagnée d'entités).
temperatures = extract_temperatures(text, self._locale)
if not self._trained or self._base_nlp is None or self._matcher is None or not text.strip(): if not self._trained or self._base_nlp is None or self._matcher is None or not text.strip():
return ProcessResult(entities=[], intent=None, score=0.0) return ProcessResult(
entities=[], intent=None, score=0.0, temperatures=temperatures
)
doc = self._base_nlp(text) doc = self._base_nlp(text)
@ -476,6 +487,10 @@ class LocalePipeline:
cats = doc.cats cats = doc.cats
if not cats: if not cats:
return ProcessResult(entities=entities, intent=None, score=0.0) return ProcessResult(
entities=entities, intent=None, score=0.0, temperatures=temperatures
)
intent = max(cats, key=cats.get) intent = max(cats, key=cats.get)
return ProcessResult(entities=entities, intent=intent, score=cats[intent]) return ProcessResult(
entities=entities, intent=intent, score=cats[intent], temperatures=temperatures
)

View file

@ -9,7 +9,7 @@ import logging
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from ..pipeline_registry import registry from ..pipeline_registry import registry
from ..schemas import EntityPayload, ProcessRequest, ProcessResponse from ..schemas import EntityPayload, ProcessRequest, ProcessResponse, TemperaturePayload
from ..security import require_valid_secret from ..security import require_valid_secret
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -34,6 +34,16 @@ def process(request: ProcessRequest) -> ProcessResponse:
{"uid": entity.uid, "start": entity.start, "end": entity.end, "kind": entity.kind} {"uid": entity.uid, "start": entity.start, "end": entity.end, "kind": entity.kind}
for entity in result.entities for entity in result.entities
], ],
"temperatures": [
{
"start": temp.start,
"end": temp.end,
"celsius": temp.celsius,
"gas_mark": temp.gas_mark,
"qualitative": temp.qualitative,
}
for temp in result.temperatures
],
"intent": result.intent, "intent": result.intent,
"score": result.score, "score": result.score,
}, },
@ -44,6 +54,17 @@ def process(request: ProcessRequest) -> ProcessResponse:
EntityPayload(uid=entity.uid, start=entity.start, end=entity.end, kind=entity.kind) EntityPayload(uid=entity.uid, start=entity.start, end=entity.end, kind=entity.kind)
for entity in result.entities for entity in result.entities
], ],
temperatures=[
TemperaturePayload(
start=temp.start,
end=temp.end,
celsius=temp.celsius,
gas_mark=temp.gas_mark,
qualitative=temp.qualitative,
raw=temp.raw,
)
for temp in result.temperatures
],
intent=result.intent, intent=result.intent,
score=result.score, score=result.score,
) )

View file

@ -39,10 +39,27 @@ class EntityPayload(BaseModel):
kind: Literal["technique", "utensil"] = "technique" kind: Literal["technique", "utensil"] = "technique"
class TemperaturePayload(BaseModel):
"""Une température reconnue dans `text` — voir
`temperature_extraction.Temperature`. Ajout additif au contrat (les
clients plus anciens l'ignorent) : liste `temperatures` à part des
`entities`, car une température porte des champs structurés
(`celsius` / `gas_mark` / `qualitative`) qui ne rentrent pas dans le
`uid` d'un `EntityPayload`. Au moins un des trois est non nul."""
start: int
end: int
celsius: float | None = None
gas_mark: int | None = None
qualitative: Literal["low", "medium", "high"] | None = None
raw: str
class ProcessResponse(BaseModel): class ProcessResponse(BaseModel):
entities: list[EntityPayload] entities: list[EntityPayload]
intent: str | None intent: str | None
score: float score: float
temperatures: list[TemperaturePayload] = []
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -0,0 +1,161 @@
"""Extraction des températures mentionnées dans une étape de recette — 3ᵉ
type de métadonnée de clause, à côté des techniques et des ustensiles (voir
`LocalePipeline.process`).
Contrairement aux deux autres, ce n'est pas un `PhraseMatcher` : une
température est numérique/motif (« 180 °C », « thermostat 6 »,
« feu doux »), pas un mot d'un vocabulaire fini. De simples expressions
régulières suffisent et n'ont besoin ni du modèle spaCy ni de
l'entraînement.
Trois formes, non exclusives (une clause peut n'en avoir aucune) :
- `celsius` une valeur en degrés Celsius (« 180 °C », « 180 degrés »,
« 180° »). Une valeur en Fahrenheit (« 350 °F », rare en FR) est
convertie et arrondie.
- `gas_mark` un numéro de thermostat / gas mark (« th. 6 »,
« thermostat 7 », « gas mark 4 »).
- `qualitative` une intensité de feu qualitative, normalisée en
`"low" | "medium" | "high"` (« feu doux/moyen/vif/fort »,
« low/medium/high heat »).
Les offsets `[start, end)` suivent la même convention que `Entity`
(`String.prototype.slice` côté `apps/api`).
"""
import re
from dataclasses import dataclass
# Intensités qualitatives -> échelle normalisée à 3 niveaux.
_QUALITATIVE_FR: dict[str, str] = {
"doux": "low",
"moyen": "medium",
"moyen-vif": "medium",
"vif": "high",
"fort": "high",
}
_QUALITATIVE_EN: dict[str, str] = {
"low": "low",
"medium": "medium",
"medium-high": "medium",
"high": "high",
}
# `re.IGNORECASE | re.UNICODE` sur tous les motifs — le texte d'entrée n'est
# pas normalisé ici (contrairement au `PhraseMatcher`), les accents de
# « degrés » comptent donc, d'où `degr[ée]s?`.
_FLAGS = re.IGNORECASE | re.UNICODE
# Fahrenheit d'abord (motif plus spécifique : le `f` explicite), sinon un
# nombre suivi d'un signe degré / « degré(s) » est lu en Celsius.
_FAHRENHEIT_RE = re.compile(r"(\d{2,3})\s*(?:°\s*f|degr[ée]s?\s*fahrenheit|°f)\b", _FLAGS)
_CELSIUS_RE = re.compile(
r"(\d{2,3})\s*(?:°\s*c\b|°(?!\s*f)|degr[ée]s?(?:\s*(?:celsius|c))?\b)",
_FLAGS,
)
_GAS_MARK_RE = re.compile(r"\b(?:th\.?|thermostat|gas\s*mark)\s*(\d{1,2})\b", _FLAGS)
_QUALITATIVE_RE = re.compile(
r"\b(?:feu\s+(doux|moyen-vif|moyen|vif|fort)"
r"|(low|medium-high|medium|high)\s+heat)\b",
_FLAGS,
)
@dataclass
class Temperature:
"""Une température trouvée dans `text`. Au moins un de `celsius` /
`gas_mark` / `qualitative` est non nul ; `raw` est le fragment exact
reconnu (`text[start:end]`)."""
start: int
end: int
celsius: float | None
gas_mark: int | None
qualitative: str | None
raw: str
def _fahrenheit_to_celsius(fahrenheit: float) -> float:
return round((fahrenheit - 32) * 5 / 9)
def extract_temperatures(text: str, locale: str) -> list[Temperature]:
"""Toutes les températures de `text`, ordonnées par position, sans
chevauchement (le fragment le plus long gagne à position égale).
`locale` n'influe que sur le dictionnaire qualitatif retenu — les
motifs numériques sont communs."""
if not text.strip():
return []
qualitative_map = _QUALITATIVE_EN if locale == "en" else _QUALITATIVE_FR
found: list[Temperature] = []
for match in _FAHRENHEIT_RE.finditer(text):
found.append(
Temperature(
start=match.start(),
end=match.end(),
celsius=_fahrenheit_to_celsius(int(match.group(1))),
gas_mark=None,
qualitative=None,
raw=match.group(0),
)
)
fahrenheit_spans = [(temp.start, temp.end) for temp in found]
for match in _CELSIUS_RE.finditer(text):
# Un « 350 °F » a déjà été capté par la passe Fahrenheit — ne pas le
# relire en Celsius sur le nombre seul.
if any(start <= match.start() < end for start, end in fahrenheit_spans):
continue
found.append(
Temperature(
start=match.start(),
end=match.end(),
celsius=float(int(match.group(1))),
gas_mark=None,
qualitative=None,
raw=match.group(0),
)
)
for match in _GAS_MARK_RE.finditer(text):
found.append(
Temperature(
start=match.start(),
end=match.end(),
celsius=None,
gas_mark=int(match.group(1)),
qualitative=None,
raw=match.group(0),
)
)
for match in _QUALITATIVE_RE.finditer(text):
raw_level = (match.group(1) or match.group(2) or "").lower()
level = qualitative_map.get(raw_level) or _QUALITATIVE_FR.get(raw_level) or _QUALITATIVE_EN.get(raw_level)
if level is None:
continue
found.append(
Temperature(
start=match.start(),
end=match.end(),
celsius=None,
gas_mark=None,
qualitative=level,
raw=match.group(0),
)
)
# Résolution des chevauchements : à position de départ égale (ou
# imbrication), on garde le fragment le plus long, puis on avance.
found.sort(key=lambda temp: (temp.start, -(temp.end - temp.start)))
resolved: list[Temperature] = []
last_end = -1
for temp in found:
if temp.start < last_end:
continue
resolved.append(temp)
last_end = temp.end
return resolved

View file

@ -22,7 +22,7 @@ def test_process_against_an_unsupported_locale_returns_empty_result(client: Test
"/v1/process", headers=_HEADERS, json={"locale": "de", "text": "faire mijoter à feu doux"} "/v1/process", headers=_HEADERS, json={"locale": "de", "text": "faire mijoter à feu doux"}
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"entities": [], "intent": None, "score": 0.0} assert response.json() == {"entities": [], "intent": None, "score": 0.0, "temperatures": []}
def test_process_returns_entities_and_intent_for_a_real_corpus_sentence(client: TestClient): def test_process_returns_entities_and_intent_for_a_real_corpus_sentence(client: TestClient):
@ -48,4 +48,27 @@ def test_process_matches_english_text_against_the_english_trained_vocabulary(cli
def test_process_with_blank_text_returns_empty_result(client: TestClient): def test_process_with_blank_text_returns_empty_result(client: TestClient):
response = client.post("/v1/process", headers=_HEADERS, json={"locale": "fr", "text": " "}) response = client.post("/v1/process", headers=_HEADERS, json={"locale": "fr", "text": " "})
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"entities": [], "intent": None, "score": 0.0} assert response.json() == {"entities": [], "intent": None, "score": 0.0, "temperatures": []}
def test_process_extracts_temperatures_alongside_entities(client: TestClient):
response = client.post(
"/v1/process",
headers=_HEADERS,
json={"locale": "fr", "text": "Enfourner à 180°C puis finir à feu doux"},
)
assert response.status_code == 200
body = response.json()
temps = body["temperatures"]
assert {
"start": 12,
"end": 17,
"celsius": 180.0,
"gas_mark": None,
"qualitative": None,
"raw": "180°C",
} in temps
assert any(
temp["qualitative"] == "low" and temp["celsius"] is None and temp["raw"] == "feu doux"
for temp in temps
)

View file

@ -0,0 +1,82 @@
"""`temperature_extraction.extract_temperatures` — regex pure, sans modèle
ni entraînement (voir le module)."""
from intent_service.temperature_extraction import extract_temperatures
def test_no_temperature_returns_empty_list():
assert extract_temperatures("Émincer les oignons finement", "fr") == []
assert extract_temperatures(" ", "fr") == []
def test_celsius_with_degree_sign_and_c():
[temp] = extract_temperatures("Cuire à 180°C pendant 30 min", "fr")
assert temp.celsius == 180.0
assert temp.gas_mark is None
assert temp.qualitative is None
assert temp.raw == "180°C"
assert (temp.start, temp.end) == (8, 13)
def test_celsius_spelled_out_degrees():
[temp] = extract_temperatures("Préchauffer le four à 200 degrés", "fr")
assert temp.celsius == 200.0
assert temp.raw == "200 degrés"
def test_bare_degree_sign_reads_as_celsius():
[temp] = extract_temperatures("Monter le four à 240°", "fr")
assert temp.celsius == 240.0
def test_fahrenheit_is_converted_and_rounded():
[temp] = extract_temperatures("Bake at 350°F", "en")
assert temp.celsius == 177.0 # round((350 - 32) * 5 / 9)
assert temp.raw == "350°F"
def test_thermostat_and_gas_mark():
[fr] = extract_temperatures("Enfourner à thermostat 6", "fr")
assert fr.gas_mark == 6
assert fr.celsius is None
[short] = extract_temperatures("Cuisson à th. 7", "fr")
assert short.gas_mark == 7
[en] = extract_temperatures("Cook at gas mark 4", "en")
assert en.gas_mark == 4
def test_qualitative_heat_fr():
levels = {
extract_temperatures(f"Faire revenir à feu {word}", "fr")[0].qualitative
for word in ("doux", "moyen", "vif", "fort")
}
assert levels == {"low", "medium", "high"}
[temp] = extract_temperatures("Laisser mijoter à feu doux", "fr")
assert temp.qualitative == "low"
assert temp.celsius is None and temp.gas_mark is None
assert temp.raw == "feu doux"
def test_qualitative_heat_en():
[temp] = extract_temperatures("Sear over high heat", "en")
assert temp.qualitative == "high"
assert temp.raw == "high heat"
def test_multiple_temperatures_in_order_without_overlap():
temps = extract_temperatures("Saisir à feu vif puis enfourner à 180°C, finir à th. 4", "fr")
assert [(t.qualitative, t.celsius, t.gas_mark) for t in temps] == [
("high", None, None),
(None, 180.0, None),
(None, None, 4),
]
assert [t.start for t in temps] == sorted(t.start for t in temps)
def test_fahrenheit_not_double_counted_as_celsius():
temps = extract_temperatures("Bake at 400°F", "en")
assert len(temps) == 1
assert temps[0].celsius == 204.0