From a04d7961677745294139102330bb7478cddd445c Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 29 Aug 2026 17:38:02 +0200 Subject: [PATCH] feat(tech-steps): detecte les temperatures dans les etapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../migration.sql | 22 +++ apps/api/prisma/schema.prisma | 35 ++++ .../recipe-matching/intent-service-client.ts | 37 +++- .../lib/recipe-matching/tech-step-matcher.ts | 20 ++- .../recipe-tech-step-correction.service.ts | 10 +- apps/api/src/modules/recipe/recipe.service.ts | 42 ++++- .../src/modules/sources/sources.service.ts | 6 + apps/api/test/recipe.test.ts | 36 ++++ .../component/highlight-tech-steps.cy.tsx | 86 +++++++++- apps/web/src/features/recipes/recipes.scss | 28 +++ .../recipes/steps/StepDescription.tsx | 65 ++++++- .../recipes/steps/highlight-tech-steps.ts | 96 ++++++++++- apps/web/src/locales/fr/translation.json | 9 + packages/shared/src/types/recipe.ts | 39 ++++- .../intent_service/locale_pipeline.py | 21 ++- .../intent_service/routes/process.py | 23 ++- .../intent_service/schemas.py | 17 ++ .../intent_service/temperature_extraction.py | 161 ++++++++++++++++++ .../tests/test_routes_process.py | 27 ++- .../tests/test_temperature_extraction.py | 82 +++++++++ 20 files changed, 837 insertions(+), 25 deletions(-) create mode 100644 apps/api/prisma/migrations/20260829120000_step_tech_step_temperature/migration.sql create mode 100644 services/tech-step-intent-service/intent_service/temperature_extraction.py create mode 100644 services/tech-step-intent-service/tests/test_temperature_extraction.py diff --git a/apps/api/prisma/migrations/20260829120000_step_tech_step_temperature/migration.sql b/apps/api/prisma/migrations/20260829120000_step_tech_step_temperature/migration.sql new file mode 100644 index 0000000..ccb949f --- /dev/null +++ b/apps/api/prisma/migrations/20260829120000_step_tech_step_temperature/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f90060e..01c87e0 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -810,6 +810,9 @@ model StepTechStep { /// Utensils mentioned in the same clause as this technique occurrence — /// see `StepTechStepUtensil`. utensils StepTechStepUtensil[] + /// Temperatures mentioned in the same clause as this technique occurrence + /// — see `StepTechStepTemperature`. + temperatures StepTechStepTemperature[] @@id([stepId, order]) @@map("step_tech_step") @@ -868,6 +871,38 @@ model StepTechStepUtensil { @@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 — /// captures ADD (a missing technique the classifier didn't find), /// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape: diff --git a/apps/api/src/lib/recipe-matching/intent-service-client.ts b/apps/api/src/lib/recipe-matching/intent-service-client.ts index b3eb363..2dd5542 100644 --- a/apps/api/src/lib/recipe-matching/intent-service-client.ts +++ b/apps/api/src/lib/recipe-matching/intent-service-client.ts @@ -25,11 +25,28 @@ export interface IntentServiceEntity { 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 { entities: IntentServiceEntity[]; intent: string | null; score: number; + temperatures: IntentServiceTemperature[]; } /** @@ -88,10 +105,26 @@ export class IntentServiceClient { */ public async process(locale: string, text: string): Promise { 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 & { + temperatures: (Omit & { gas_mark: number | null })[]; + } + >("/v1/process", { method: "POST", 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) { throw err; } diff --git a/apps/api/src/lib/recipe-matching/tech-step-matcher.ts b/apps/api/src/lib/recipe-matching/tech-step-matcher.ts index f218b5f..6485e6f 100644 --- a/apps/api/src/lib/recipe-matching/tech-step-matcher.ts +++ b/apps/api/src/lib/recipe-matching/tech-step-matcher.ts @@ -5,7 +5,7 @@ import { loadIngredientCatalog, loadUnitCatalog, } 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 @@ -114,6 +114,7 @@ export interface TechStepMatch { contextEnd: number; ingredients: IngredientMention[]; utensils: UtensilMention[]; + temperatures: TemperatureMention[]; } /** @@ -129,6 +130,15 @@ export interface UtensilMention { 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. */ 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. */ @@ -408,6 +418,7 @@ export class TechStepClassifierService { .filter((entity) => entity.kind === "technique") .map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end })); const utensilEntities = nerResult.entities.filter((entity) => entity.kind === "utensil"); + const temperatureMentions = nerResult.temperatures; const clauses = splitIntoClauses(description, candidates); const matches: TechStepMatch[] = []; @@ -442,6 +453,12 @@ export class TechStepClassifierService { : [{ 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({ techStepId, start: span.start, @@ -450,6 +467,7 @@ export class TechStepClassifierService { contextEnd: clause.end, ingredients, utensils, + temperatures, }); } diff --git a/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts b/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts index b106f2c..27aa906 100644 --- a/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts +++ b/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts @@ -446,10 +446,11 @@ export async function submitTechStepCorrection( }, ); - // Same nested `ingredients`/`utensils` include as `recipe.service.ts`'s - // `recipeInclude` — `toStepTechStepViews` (reused below) expects it, - // so the fresh sequence read right after a manual correction resolves - // exactly the same way a normal `GET /recipes/:id` would. + // Same nested `ingredients`/`utensils`/`temperatures` include as + // `recipe.service.ts`'s `recipeInclude` — `toStepTechStepViews` + // (reused below) expects it, so the fresh sequence read right after a + // manual correction resolves exactly the same way a normal + // `GET /recipes/:id` would. const freshTechSteps = await tx.stepTechStep.findMany({ where: { stepId: step.id }, orderBy: { order: "asc" }, @@ -467,6 +468,7 @@ export async function submitTechStepCorrection( }, }, utensils: { include: { utensil: true } }, + temperatures: true, }, }); diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index b63101c..fcea207 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -68,6 +68,7 @@ function recipeInclude(viewerId: number) { }, }, utensils: { include: { utensil: true } }, + temperatures: true, }, }, }, @@ -175,8 +176,17 @@ export function toStepTechStepViews( ): StepTechStepView[] { const views: StepTechStepView[] = []; for (const stepTechStep of techSteps) { - const { start, end, contextStart, contextEnd, techStep, source, ingredients, utensils } = - stepTechStep; + const { + start, + end, + contextStart, + contextEnd, + techStep, + source, + ingredients, + utensils, + temperatures, + } = stepTechStep; if (start === null || end === null) continue; views.push({ techStep: { id: techStep.id, key: techStep.key }, @@ -206,6 +216,24 @@ export function toStepTechStepViews( end: stepTechStepUtensil.end, 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; @@ -690,6 +718,16 @@ async function createRecipeInternal( 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, + })), + }, })), }, })), diff --git a/apps/api/src/modules/sources/sources.service.ts b/apps/api/src/modules/sources/sources.service.ts index 639fa42..bd2d803 100644 --- a/apps/api/src/modules/sources/sources.service.ts +++ b/apps/api/src/modules/sources/sources.service.ts @@ -260,6 +260,12 @@ export async function previewSourceItem( ? [{ 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, + })), }, ] : []; diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index 5b35d94..472833f 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -404,6 +404,42 @@ describe("Recipes", () => { 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 () => { const { agent } = await signup(); const tomate = await ingredientId("tomato"); diff --git a/apps/web/cypress/component/highlight-tech-steps.cy.tsx b/apps/web/cypress/component/highlight-tech-steps.cy.tsx index f06a6c4..f3e3c24 100644 --- a/apps/web/cypress/component/highlight-tech-steps.cy.tsx +++ b/apps/web/cypress/component/highlight-tech-steps.cy.tsx @@ -1,5 +1,8 @@ -import type { StepTechStepView } from "@batch-cooking/shared"; -import { splitDescriptionByTechSteps } from "../../src/features/recipes/steps/highlight-tech-steps"; +import type { StepTechStepTemperatureView, StepTechStepView } from "@batch-cooking/shared"; +import { + splitDescriptionByTechSteps, + splitDescriptionSegments, +} from "../../src/features/recipes/steps/highlight-tech-steps"; // Pure logic, no DOM/mount needed — reuses the component-test runner // (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 { + 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); + }); +}); diff --git a/apps/web/src/features/recipes/recipes.scss b/apps/web/src/features/recipes/recipes.scss index 0c2e360..11543d9 100644 --- a/apps/web/src/features/recipes/recipes.scss +++ b/apps/web/src/features/recipes/recipes.scss @@ -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 // was found in) used to be highlighted here too, more subtly — turned back // off (see `StepDescription.tsx`'s doc comment): the backend still diff --git a/apps/web/src/features/recipes/steps/StepDescription.tsx b/apps/web/src/features/recipes/steps/StepDescription.tsx index 3b4d650..e24421f 100644 --- a/apps/web/src/features/recipes/steps/StepDescription.tsx +++ b/apps/web/src/features/recipes/steps/StepDescription.tsx @@ -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 { useTranslation } from "react-i18next"; import { Tooltip } from "../../../components/ui/Tooltip"; -import { splitDescriptionByTechSteps } from "./highlight-tech-steps"; +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, +): 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 @@ -66,7 +93,19 @@ export function StepDescription({ const [liveTechSteps, setLiveTechSteps] = useState(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(null); const { getSelectionRange } = useTextSelection(containerRef); @@ -169,6 +208,26 @@ export function StepDescription({ // 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 ( + + + {segment.text} + + + ); + } + if (!segment.techStep || !segment.isKeyword) { // Context-only or plain run — rendered as plain text in // read-only mode, same as before this component supported diff --git a/apps/web/src/features/recipes/steps/highlight-tech-steps.ts b/apps/web/src/features/recipes/steps/highlight-tech-steps.ts index f98bf52..21b68e8 100644 --- a/apps/web/src/features/recipes/steps/highlight-tech-steps.ts +++ b/apps/web/src/features/recipes/steps/highlight-tech-steps.ts @@ -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 @@ -20,6 +20,15 @@ export interface DescriptionSegment { 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; } /** @@ -139,3 +148,88 @@ export function splitDescriptionByTechSteps( } 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; +} diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json index de38753..7225587 100644 --- a/apps/web/src/locales/fr/translation.json +++ b/apps/web/src/locales/fr/translation.json @@ -166,6 +166,15 @@ "cancelDeleteButton": "Annuler", "ingredientsTitle": "Ingrédients", "stepsTitle": "Préparation", + "temperature": { + "celsius": "{{value}} °C", + "gasMark": "Thermostat {{value}}", + "qualitative": { + "low": "Feu doux", + "medium": "Feu moyen", + "high": "Feu vif" + } + }, "techStepCorrection": { "selectionLabel": "« {{text}} »", "removeMatch": "Aucune technique ici", diff --git a/packages/shared/src/types/recipe.ts b/packages/shared/src/types/recipe.ts index 076db7d..b3e9cb8 100644 --- a/packages/shared/src/types/recipe.ts +++ b/packages/shared/src/types/recipe.ts @@ -57,10 +57,11 @@ export interface RecipeIngredientView { * `applyManualCorrection`). `StepDescription.tsx` renders the two with a * different highlight color so a viewer can tell which is which. * - * `ingredients`/`utensils` are the metadata found in this technique's own - * clause (see `tech-step-matcher.ts`'s `TechStepMatch` — same source data, - * just resolved to full reference views here instead of bare ids) — `[]` - * when nothing was mentioned alongside this technique. + * `ingredients`/`utensils`/`temperatures` are the metadata found in this + * technique's own clause (see `tech-step-matcher.ts`'s `TechStepMatch` — + * same source data, just resolved to full reference views here instead of + * bare ids) — `[]` when nothing of that kind was mentioned alongside this + * technique. */ export interface StepTechStepView { techStep: TechStepView; @@ -71,6 +72,36 @@ export interface StepTechStepView { source: "auto" | "manual"; ingredients: StepTechStepIngredientView[]; 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"; } /** diff --git a/services/tech-step-intent-service/intent_service/locale_pipeline.py b/services/tech-step-intent-service/intent_service/locale_pipeline.py index 4d9b8c0..04193c2 100644 --- a/services/tech-step-intent-service/intent_service/locale_pipeline.py +++ b/services/tech-step-intent-service/intent_service/locale_pipeline.py @@ -31,6 +31,7 @@ from spacy.training import Example from spacy.util import filter_spans, fix_random_seed, minibatch from . import utensil_vocabulary +from .temperature_extraction import Temperature, extract_temperatures from .text_normalization import normalize_text logger = logging.getLogger(__name__) @@ -190,6 +191,9 @@ class ProcessResult: entities: list[Entity] intent: str | None 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): @@ -415,8 +419,15 @@ class LocalePipeline: textcat n'a alors jamais été construit — voir son propre 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(): - return ProcessResult(entities=[], intent=None, score=0.0) + return ProcessResult( + entities=[], intent=None, score=0.0, temperatures=temperatures + ) doc = self._base_nlp(text) @@ -476,6 +487,10 @@ class LocalePipeline: cats = doc.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) - return ProcessResult(entities=entities, intent=intent, score=cats[intent]) + return ProcessResult( + entities=entities, intent=intent, score=cats[intent], temperatures=temperatures + ) diff --git a/services/tech-step-intent-service/intent_service/routes/process.py b/services/tech-step-intent-service/intent_service/routes/process.py index f137729..02a3903 100644 --- a/services/tech-step-intent-service/intent_service/routes/process.py +++ b/services/tech-step-intent-service/intent_service/routes/process.py @@ -9,7 +9,7 @@ import logging from fastapi import APIRouter, Depends from ..pipeline_registry import registry -from ..schemas import EntityPayload, ProcessRequest, ProcessResponse +from ..schemas import EntityPayload, ProcessRequest, ProcessResponse, TemperaturePayload from ..security import require_valid_secret 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} 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, "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) 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, score=result.score, ) diff --git a/services/tech-step-intent-service/intent_service/schemas.py b/services/tech-step-intent-service/intent_service/schemas.py index fe341b8..12dac93 100644 --- a/services/tech-step-intent-service/intent_service/schemas.py +++ b/services/tech-step-intent-service/intent_service/schemas.py @@ -39,10 +39,27 @@ class EntityPayload(BaseModel): 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): entities: list[EntityPayload] intent: str | None score: float + temperatures: list[TemperaturePayload] = [] # --------------------------------------------------------------------------- diff --git a/services/tech-step-intent-service/intent_service/temperature_extraction.py b/services/tech-step-intent-service/intent_service/temperature_extraction.py new file mode 100644 index 0000000..212474c --- /dev/null +++ b/services/tech-step-intent-service/intent_service/temperature_extraction.py @@ -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 diff --git a/services/tech-step-intent-service/tests/test_routes_process.py b/services/tech-step-intent-service/tests/test_routes_process.py index 443d698..e6ccdca 100644 --- a/services/tech-step-intent-service/tests/test_routes_process.py +++ b/services/tech-step-intent-service/tests/test_routes_process.py @@ -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"} ) 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): @@ -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): response = client.post("/v1/process", headers=_HEADERS, json={"locale": "fr", "text": " "}) 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 + ) diff --git a/services/tech-step-intent-service/tests/test_temperature_extraction.py b/services/tech-step-intent-service/tests/test_temperature_extraction.py new file mode 100644 index 0000000..e1bcde0 --- /dev/null +++ b/services/tech-step-intent-service/tests/test_temperature_extraction.py @@ -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