Merge pull request 'feat(tech-steps): détecte les températures dans les étapes' (#19) from feat/temperature-metadata into feat/merge-admin-into-web
Reviewed-on: #19
This commit is contained in:
commit
b1e7fbb5fb
22 changed files with 856 additions and 25 deletions
|
|
@ -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;
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})),
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,18 @@ describe("tech-step-matcher", () => {
|
||||||
contextEnd: text.length,
|
contextEnd: text.length,
|
||||||
ingredients: [],
|
ingredients: [],
|
||||||
utensils: [],
|
utensils: [],
|
||||||
|
// "feu doux" [16, 24) is a qualitative-heat mention in this same
|
||||||
|
// clause — see `temperature_extraction.py`.
|
||||||
|
temperatures: [
|
||||||
|
{
|
||||||
|
start: 16,
|
||||||
|
end: 24,
|
||||||
|
celsius: null,
|
||||||
|
gasMark: null,
|
||||||
|
qualitative: "low",
|
||||||
|
raw: "feu doux",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(text.slice(6, 13).toLowerCase()).to.equal("mijoter");
|
expect(text.slice(6, 13).toLowerCase()).to.equal("mijoter");
|
||||||
|
|
@ -341,6 +353,7 @@ describe("tech-step-matcher", () => {
|
||||||
// (see `IntentServiceEntity.kind`).
|
// (see `IntentServiceEntity.kind`).
|
||||||
ingredients: [],
|
ingredients: [],
|
||||||
utensils: [{ utensilId: panId, start: 9, end: 14 }],
|
utensils: [{ utensilId: panId, start: 9, end: 14 }],
|
||||||
|
temperatures: [],
|
||||||
});
|
});
|
||||||
expect(result[1]).to.deep.equal({
|
expect(result[1]).to.deep.equal({
|
||||||
techStepId: meltId,
|
techStepId: meltId,
|
||||||
|
|
@ -357,6 +370,7 @@ describe("tech-step-matcher", () => {
|
||||||
{ ingredientId: butterId, start: 50, end: 56, quantity: null, unitId: null },
|
{ ingredientId: butterId, start: 50, end: 56, quantity: null, unitId: null },
|
||||||
],
|
],
|
||||||
utensils: [],
|
utensils: [],
|
||||||
|
temperatures: [],
|
||||||
});
|
});
|
||||||
expect(text.slice(result[0].start, result[0].end)).to.equal("poêle chaude");
|
expect(text.slice(result[0].start, result[0].end)).to.equal("poêle chaude");
|
||||||
expect(text.slice(result[0].contextStart, result[0].contextEnd)).to.equal(
|
expect(text.slice(result[0].contextStart, result[0].contextEnd)).to.equal(
|
||||||
|
|
@ -387,6 +401,7 @@ describe("tech-step-matcher", () => {
|
||||||
{ ingredientId: butterId, start: 18, end: 24, quantity: null, unitId: null },
|
{ ingredientId: butterId, start: 18, end: 24, quantity: null, unitId: null },
|
||||||
],
|
],
|
||||||
utensils: [{ utensilId: panId, start: 45, end: 50 }],
|
utensils: [{ utensilId: panId, start: 45, end: 50 }],
|
||||||
|
temperatures: [],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
@ -405,6 +420,7 @@ describe("tech-step-matcher", () => {
|
||||||
{ ingredientId: onionId, start: 9, end: 15, quantity: null, unitId: null },
|
{ ingredientId: onionId, start: 9, end: 15, quantity: null, unitId: null },
|
||||||
],
|
],
|
||||||
utensils: [],
|
utensils: [],
|
||||||
|
temperatures: [],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(text.slice(0, 4)).to.equal("Chop");
|
expect(text.slice(0, 4)).to.equal("Chop");
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ describe("Recipe tech-step corrections", () => {
|
||||||
source: "manual",
|
source: "manual",
|
||||||
ingredients: [],
|
ingredients: [],
|
||||||
utensils: [],
|
utensils: [],
|
||||||
|
temperatures: [],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
@ -156,6 +157,7 @@ describe("Recipe tech-step corrections", () => {
|
||||||
source: "manual",
|
source: "manual",
|
||||||
ingredients: [],
|
ingredients: [],
|
||||||
utensils: [],
|
utensils: [],
|
||||||
|
temperatures: [],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
@ -319,6 +321,7 @@ describe("Recipe tech-step corrections", () => {
|
||||||
source: "manual",
|
source: "manual",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
temperatures: [],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(res.body.techSteps[0].ingredients[0].ingredient.id).to.equal(butterId);
|
expect(res.body.techSteps[0].ingredients[0].ingredient.id).to.equal(butterId);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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] = []
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in a new issue