feat(tech-steps): detecte les temperatures dans les etapes
3e type de metadonnee de clause, a cote des ingredients et des ustensiles : temperature en °C (« 180°C », « 200 degres », °F converti), numero de thermostat (« th. 6 ») et intensite qualitative (« feu doux/moyen/vif » -> low/medium/high). services/tech-step-intent-service : - `temperature_extraction.py` : `extract_temperatures(text, locale)` pur, a base de regex (independant du modele spaCy et de l'entrainement). - `ProcessResult` / `ProcessResponse` gagnent `temperatures` (ajout additif au contrat ; cle `gas_mark` en snake_case sur le fil). `process()` les extrait meme pour une locale pas encore entrainee. - Tests `test_temperature_extraction.py` (10) ; `test_routes_process.py` : 2 assertions d'egalite stricte gagnent `"temperatures": []`, +1 cas. apps/api : - `IntentServiceTemperature` (mappe `gas_mark` -> `gasMark` a la frontiere). - `TechStepMatch.temperatures` : filtrees par appartenance de span a la clause, meme regle que les ustensiles. - `model StepTechStepTemperature` (aucune FK — la valeur structuree EST la donnee) + migration manuelle `20260829120000_step_tech_step_temperature` (cascade via le TRUNCATE de `step_tech_step`, rien a ajouter a reset-db.ts). Persistance + lecture dans `recipe.service.ts` (`recipeInclude`, `toStepTechStepViews`) et l'include de sequence fraiche du service de correction ; `sources.service.ts` (apercu d'import) les fait transiter. packages/shared : `StepTechStepTemperatureView` + `temperatures` sur `StepTechStepView`. apps/web : - `splitDescriptionSegments` enrobe `splitDescriptionByTechSteps` et redecoupe les segments non-keyword autour des spans de temperature (la logique technique intriquee reste intacte). `?? []` tolere une payload d'avant `temperatures`. - `StepDescription` : surlignage `.step-temperature` + Tooltip via `temperatureLabel` ; i18n `recipes.temperature.*`. - Tests composants +4 (22 verts) ; pas d'UI de correction (v1, comme les ustensiles). Verifie : biome + tsc + `pnpm -r build` ; web 102/103 e2e (l'echec est le flake pre-existant recipe-form.feature, sans rapport) + 49/49 composants ; pytest temperature + routes 15/15. `pnpm --filter api test` (Postgres + intent-service requis) non lance ici. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
1a28d67218
commit
189df73b4c
20 changed files with 837 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 —
|
||||
/// 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:
|
||||
|
|
|
|||
|
|
@ -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<IntentServiceProcessResult> {
|
||||
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",
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
},
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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> = {},
|
||||
): 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
|
||||
// was found in) used to be highlighted here too, more subtly — turned back
|
||||
// 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 { 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, unknown>) => string,
|
||||
): string {
|
||||
if (temperature.celsius !== null) {
|
||||
return t("recipes.temperature.celsius", { value: temperature.celsius });
|
||||
}
|
||||
if (temperature.gasMark !== null) {
|
||||
return t("recipes.temperature.gasMark", { value: temperature.gasMark });
|
||||
}
|
||||
if (temperature.qualitative !== null) {
|
||||
return t(`recipes.temperature.qualitative.${temperature.qualitative}`);
|
||||
}
|
||||
return temperature.raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* A recipe step's description, with every detected technique's exact
|
||||
* matched words highlighted and given a {@link Tooltip} naming the
|
||||
|
|
@ -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<HTMLParagraphElement>(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 (
|
||||
<Tooltip key={key} content={label}>
|
||||
<span
|
||||
className={`step-temperature step-temperature--${modifier}`}
|
||||
data-offset={editable ? start : undefined}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (!segment.techStep || !segment.isKeyword) {
|
||||
// Context-only or plain run — rendered as plain text in
|
||||
// read-only mode, same as before this component supported
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
)
|
||||
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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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