3e type de metadonnee de clause, a cote des ingredients et des ustensiles : temperature en °C (« 180°C », « 200 degres », °F converti), numero de thermostat (« th. 6 ») et intensite qualitative (« feu doux/moyen/vif » -> low/medium/high). services/tech-step-intent-service : - `temperature_extraction.py` : `extract_temperatures(text, locale)` pur, a base de regex (independant du modele spaCy et de l'entrainement). - `ProcessResult` / `ProcessResponse` gagnent `temperatures` (ajout additif au contrat ; cle `gas_mark` en snake_case sur le fil). `process()` les extrait meme pour une locale pas encore entrainee. - Tests `test_temperature_extraction.py` (10) ; `test_routes_process.py` : 2 assertions d'egalite stricte gagnent `"temperatures": []`, +1 cas. apps/api : - `IntentServiceTemperature` (mappe `gas_mark` -> `gasMark` a la frontiere). - `TechStepMatch.temperatures` : filtrees par appartenance de span a la clause, meme regle que les ustensiles. - `model StepTechStepTemperature` (aucune FK — la valeur structuree EST la donnee) + migration manuelle `20260829120000_step_tech_step_temperature` (cascade via le TRUNCATE de `step_tech_step`, rien a ajouter a reset-db.ts). Persistance + lecture dans `recipe.service.ts` (`recipeInclude`, `toStepTechStepViews`) et l'include de sequence fraiche du service de correction ; `sources.service.ts` (apercu d'import) les fait transiter. packages/shared : `StepTechStepTemperatureView` + `temperatures` sur `StepTechStepView`. apps/web : - `splitDescriptionSegments` enrobe `splitDescriptionByTechSteps` et redecoupe les segments non-keyword autour des spans de temperature (la logique technique intriquee reste intacte). `?? []` tolere une payload d'avant `temperatures`. - `StepDescription` : surlignage `.step-temperature` + Tooltip via `temperatureLabel` ; i18n `recipes.temperature.*`. - Tests composants +4 (22 verts) ; pas d'UI de correction (v1, comme les ustensiles). Verifie : biome + tsc + `pnpm -r build` ; web 102/103 e2e (l'echec est le flake pre-existant recipe-form.feature, sans rapport) + 49/49 composants ; pytest temperature + routes 15/15. `pnpm --filter api test` (Postgres + intent-service requis) non lance ici. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
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
|
|
// `expect`, not for rendering. `.cy.tsx` (not `.cy.ts`) only because that's
|
|
// what `cypress.config.ts`'s component `specPattern` looks for.
|
|
|
|
/** Builds a `StepTechStepView` — `context` omitted entirely (not just undefined) when absent, matching what the API actually sends for an older, not-yet-recomputed match (see `StepTechStepView`'s own doc comment). `source` defaults to `"auto"`, the common case every test not specifically about the manual/auto distinction uses. */
|
|
function techStep(
|
|
key: string,
|
|
id: number,
|
|
start: number,
|
|
end: number,
|
|
context?: { start: number; end: number },
|
|
source: StepTechStepView["source"] = "auto",
|
|
): StepTechStepView {
|
|
return {
|
|
techStep: { id, key },
|
|
start,
|
|
end,
|
|
source,
|
|
...(context ? { contextStart: context.start, contextEnd: context.end } : {}),
|
|
};
|
|
}
|
|
|
|
describe("splitDescriptionByTechSteps", () => {
|
|
it("returns the whole description as one plain segment when there are no matches", () => {
|
|
expect(splitDescriptionByTechSteps("Servir immédiatement", [])).to.deep.equal([
|
|
{ text: "Servir immédiatement", techStep: null, isKeyword: false, source: null },
|
|
]);
|
|
});
|
|
|
|
it("splits a single keyword-only match (no context) into before/match/after segments", () => {
|
|
// "Faire mijoter à feu doux" — "mijoter" is [6, 13). Same shape as
|
|
// before context spans existed at all — the common case for a short,
|
|
// already-imperative clause where the keyword and its context coincide.
|
|
const result = splitDescriptionByTechSteps("Faire mijoter à feu doux", [
|
|
techStep("simmer", 1, 6, 13),
|
|
]);
|
|
expect(result).to.deep.equal([
|
|
{ text: "Faire ", techStep: null, isKeyword: false, source: null },
|
|
{ text: "mijoter", techStep: { id: 1, key: "simmer" }, isKeyword: true, source: "auto" },
|
|
{ text: " à feu doux", techStep: null, isKeyword: false, source: null },
|
|
]);
|
|
});
|
|
|
|
it("handles a match at the very start, with nothing before it", () => {
|
|
const result = splitDescriptionByTechSteps("Hacher les oignons", [techStep("chop", 2, 0, 6)]);
|
|
expect(result).to.deep.equal([
|
|
{ text: "Hacher", techStep: { id: 2, key: "chop" }, isKeyword: true, source: "auto" },
|
|
{ text: " les oignons", techStep: null, isKeyword: false, source: null },
|
|
]);
|
|
});
|
|
|
|
it("handles a match at the very end, with nothing after it", () => {
|
|
const result = splitDescriptionByTechSteps("Faire cuire", [techStep("cook", 3, 6, 11)]);
|
|
expect(result).to.deep.equal([
|
|
{ text: "Faire ", techStep: null, isKeyword: false, source: null },
|
|
{ text: "cuire", techStep: { id: 3, key: "cook" }, isKeyword: true, source: "auto" },
|
|
]);
|
|
});
|
|
|
|
it("handles several non-adjacent matches, preserving the plain text between them", () => {
|
|
const text = "Préchauffer la poêle, puis faire fondre le beurre";
|
|
const result = splitDescriptionByTechSteps(text, [
|
|
techStep("preheat", 4, 0, 11),
|
|
techStep("melt", 5, 27, 39),
|
|
]);
|
|
expect(result.map((s) => s.text).join("")).to.equal(text);
|
|
expect(result.filter((s) => s.techStep !== null)).to.have.length(2);
|
|
expect(result[0]).to.deep.equal({
|
|
text: "Préchauffer",
|
|
techStep: { id: 4, key: "preheat" },
|
|
isKeyword: true,
|
|
source: "auto",
|
|
});
|
|
});
|
|
|
|
it("re-sorts entries that aren't already in start order", () => {
|
|
const text = "Faire fondre le beurre puis préchauffer le four";
|
|
// Passed in techStepId order, not text order — the function must sort
|
|
// by position, not trust the input order.
|
|
const result = splitDescriptionByTechSteps(text, [
|
|
techStep("preheat", 4, 28, 39),
|
|
techStep("melt", 5, 0, 12),
|
|
]);
|
|
const matches = result.filter((s) => s.techStep !== null && s.isKeyword);
|
|
expect(matches.map((s) => s.techStep?.key)).to.deep.equal(["melt", "preheat"]);
|
|
});
|
|
|
|
it("drops a match whose end is past the end of the description", () => {
|
|
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 0, 999)]);
|
|
expect(result).to.deep.equal([
|
|
{ text: "Cuire", techStep: null, isKeyword: false, source: null },
|
|
]);
|
|
});
|
|
|
|
it("drops a match with a negative start", () => {
|
|
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, -1, 5)]);
|
|
expect(result).to.deep.equal([
|
|
{ text: "Cuire", techStep: null, isKeyword: false, source: null },
|
|
]);
|
|
});
|
|
|
|
it("drops a match whose start isn't before its end", () => {
|
|
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 3, 3)]);
|
|
expect(result).to.deep.equal([
|
|
{ text: "Cuire", techStep: null, isKeyword: false, source: null },
|
|
]);
|
|
});
|
|
|
|
it("drops a later match that overlaps one already accepted", () => {
|
|
// Two entries claiming overlapping ranges shouldn't happen in practice
|
|
// (the backend already resolves overlaps), but the splitter defends
|
|
// against it anyway rather than producing a garbled/duplicated slice.
|
|
const result = splitDescriptionByTechSteps("Cuire au four", [
|
|
techStep("bake", 3, 0, 13),
|
|
techStep("cook", 2, 0, 5),
|
|
]);
|
|
expect(result).to.deep.equal([
|
|
{ text: "Cuire au four", techStep: { id: 3, key: "bake" }, isKeyword: true, source: "auto" },
|
|
]);
|
|
});
|
|
|
|
it("returns a single empty-ish segment for an empty description with no matches", () => {
|
|
expect(splitDescriptionByTechSteps("", [])).to.deep.equal([]);
|
|
});
|
|
|
|
it("carries a manual correction's source through its segments, distinct from an auto match", () => {
|
|
const text = "Faire mijoter le riz, puis dresser dans les assiettes";
|
|
const result = splitDescriptionByTechSteps(text, [
|
|
techStep("simmer", 1, 6, 13),
|
|
techStep("plate", 2, 28, 35, undefined, "manual"),
|
|
]);
|
|
const keywordSegments = result.filter((s) => s.isKeyword);
|
|
expect(keywordSegments.map((s) => ({ key: s.techStep?.key, source: s.source }))).to.deep.equal([
|
|
{ key: "simmer", source: "auto" },
|
|
{ key: "plate", source: "manual" },
|
|
]);
|
|
});
|
|
|
|
it("never lets one match's wider context swallow another match's own keyword span", () => {
|
|
// The motivating real bug (found via live testing, not invented for
|
|
// this test): "simmer" is the only NER candidate `splitIntoClauses`
|
|
// found, so its context spans the *entire* description — before this
|
|
// was fixed, that wide context advanced `cursor` past 39, silently
|
|
// dropping "setAside"'s own keyword span (a manual correction on
|
|
// "materiel", a word with no relation to "simmer" at all) instead of
|
|
// rendering it.
|
|
const text = "Faire mijoter la sauce, puis ranger le materiel.";
|
|
const result = splitDescriptionByTechSteps(text, [
|
|
techStep("simmer", 1, 6, 13, { start: 0, end: 48 }),
|
|
techStep("setAside", 2, 39, 47, undefined, "manual"),
|
|
]);
|
|
const keywordSegments = result.filter((s) => s.isKeyword);
|
|
expect(
|
|
keywordSegments.map((s) => ({ key: s.techStep?.key, text: s.text, source: s.source })),
|
|
).to.deep.equal([
|
|
{ key: "simmer", text: "mijoter", source: "auto" },
|
|
{ key: "setAside", text: "materiel", source: "manual" },
|
|
]);
|
|
expect(result.map((s) => s.text).join("")).to.equal(text);
|
|
});
|
|
|
|
describe("with a context span wider than the keyword", () => {
|
|
it("splits into context-before / keyword / context-after around a keyword in the middle of its clause", () => {
|
|
// The motivating example: "Dans une poêle chaude, faire chauffer une
|
|
// noix de beurre" — `preheat`'s keyword is "poêle chaude", its
|
|
// context is the whole "Dans une poêle chaude" clause around it.
|
|
const text = "Dans une poêle chaude, faire chauffer une noix de beurre";
|
|
const result = splitDescriptionByTechSteps(text, [
|
|
techStep("preheat", 4, 9, 21, { start: 0, end: 21 }),
|
|
]);
|
|
expect(result).to.deep.equal([
|
|
{
|
|
text: "Dans une ",
|
|
techStep: { id: 4, key: "preheat" },
|
|
isKeyword: false,
|
|
source: "auto",
|
|
},
|
|
{
|
|
text: "poêle chaude",
|
|
techStep: { id: 4, key: "preheat" },
|
|
isKeyword: true,
|
|
source: "auto",
|
|
},
|
|
{
|
|
text: ", faire chauffer une noix de beurre",
|
|
techStep: null,
|
|
isKeyword: false,
|
|
source: null,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("omits the context-before segment when the keyword starts right at the context's own start", () => {
|
|
const result = splitDescriptionByTechSteps("préchauffer le four", [
|
|
techStep("preheat", 4, 0, 11, { start: 0, end: 19 }),
|
|
]);
|
|
expect(result).to.deep.equal([
|
|
{
|
|
text: "préchauffer",
|
|
techStep: { id: 4, key: "preheat" },
|
|
isKeyword: true,
|
|
source: "auto",
|
|
},
|
|
{ text: " le four", techStep: { id: 4, key: "preheat" }, isKeyword: false, source: "auto" },
|
|
]);
|
|
});
|
|
|
|
it("omits the context-after segment when the keyword ends right at the context's own end", () => {
|
|
const result = splitDescriptionByTechSteps("mettre le four à préchauffer", [
|
|
techStep("preheat", 4, 17, 28, { start: 7, end: 28 }),
|
|
]);
|
|
expect(result).to.deep.equal([
|
|
{ text: "mettre ", techStep: null, isKeyword: false, source: null },
|
|
{
|
|
text: "le four à ",
|
|
techStep: { id: 4, key: "preheat" },
|
|
isKeyword: false,
|
|
source: "auto",
|
|
},
|
|
{
|
|
text: "préchauffer",
|
|
techStep: { id: 4, key: "preheat" },
|
|
isKeyword: true,
|
|
source: "auto",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("falls back to a keyword-only segment when context is absent (an older, not-yet-recomputed match)", () => {
|
|
const result = splitDescriptionByTechSteps("Faire mijoter à feu doux", [
|
|
techStep("simmer", 1, 6, 13),
|
|
]);
|
|
expect(result.some((s) => s.techStep !== null && !s.isKeyword)).to.equal(false);
|
|
});
|
|
|
|
it("drops an entry whose context doesn't actually contain its own keyword span", () => {
|
|
const result = splitDescriptionByTechSteps("Cuire au four", [
|
|
// contextEnd (5) is before the keyword's own end (13) — malformed.
|
|
techStep("bake", 3, 0, 13, { start: 0, end: 5 }),
|
|
]);
|
|
expect(result).to.deep.equal([
|
|
{ text: "Cuire au four", techStep: null, isKeyword: false, source: null },
|
|
]);
|
|
});
|
|
});
|
|
});
|
|
|
|
/** 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);
|
|
});
|
|
});
|