Compare commits

...

5 commits

Author SHA1 Message Date
371ed5593f Merge pull request 'docs: référence unique de toutes les variables d'environnement' (#20) from docs/environment-variables into feat/merge-admin-into-web
Some checks failed
CI / lint (push) Successful in 2m11s
CI / build (push) Successful in 2m39s
CI / e2e (push) Successful in 12m29s
CI / test (push) Failing after 23m54s
CI / intent-service-test (push) Successful in 28m5s
Reviewed-on: #20
2026-08-29 21:27:38 +02:00
9a9401ca8d docs: reference unique de toutes les variables d'environnement
Some checks failed
CI / lint (push) Successful in 2m17s
CI / build (push) Successful in 2m48s
CI / e2e (push) Failing after 15m19s
CI / test (push) Failing after 22m57s
CI / intent-service-test (push) Failing after 24m1s
Ajoute specs/environment.md : liste et explique chaque variable d'env de
l'appli (API, front, service NLP, worker LLM, Postgres, Compose) —
obligatoire ou non, defaut, fichier ou la poser, role, et la matrice des
secrets partages qui doivent correspondre entre deux bouts.

Resume les schemas de validation (apps/api/src/config/env.ts,
services/tech-step-llm-worker/src/config.ts,
tech-step-intent-service/config.py) qui restent la source de verite, plus
les 7 fichiers *.env.example et docker-compose.yml.

README : pointeur vers ce document depuis la section Installation, et
mention explicite des .env des deux services.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 21:21:37 +02:00
b1e7fbb5fb 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
Some checks failed
CI / lint (push) Successful in 2m8s
CI / build (push) Successful in 4m11s
CI / test (push) Has been cancelled
CI / intent-service-test (push) Has been cancelled
CI / e2e (push) Has been cancelled
Reviewed-on: #19
2026-08-29 21:15:56 +02:00
fd62dbbabe test(tech-steps): ajoute temperatures aux fixtures StepTechStep
All checks were successful
CI / lint (push) Successful in 2m27s
CI / build (push) Successful in 3m33s
CI / e2e (push) Successful in 8m27s
CI / intent-service-test (push) Successful in 11m14s
CI / test (push) Successful in 20m57s
CI: 7 `deep.equal` sur un `TechStepMatch` / `StepTechStepView` echouaient
(l'objet reel a une cle de plus depuis l'ajout de `temperatures`).

- tech-step-matcher.test.ts x4 : `temperatures: []`, sauf le cas
  « Faire mijoter a feu doux » ou « feu doux » [16,24) est une mention
  qualitative -> `qualitative: "low"`.
- recipe-tech-step-correction.test.ts x3 : `temperatures: []` (l'etape
  fixture « Faire mijoter la sauce. » n'a pas de mention de temperature).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 19:59:52 +02:00
189df73b4c feat(tech-steps): detecte les temperatures dans les etapes
Some checks failed
CI / lint (push) Successful in 2m33s
CI / intent-service-test (push) Failing after 31s
CI / build (push) Successful in 3m30s
CI / e2e (push) Successful in 11m14s
CI / test (push) Failing after 24m39s
3e type de metadonnee de clause, a cote des ingredients et des ustensiles :
temperature en °C (« 180°C », « 200 degres », °F converti), numero de
thermostat (« th. 6 ») et intensite qualitative (« feu doux/moyen/vif » ->
low/medium/high).

services/tech-step-intent-service :
- `temperature_extraction.py` : `extract_temperatures(text, locale)` pur,
  a base de regex (independant du modele spaCy et de l'entrainement).
- `ProcessResult` / `ProcessResponse` gagnent `temperatures` (ajout additif
  au contrat ; cle `gas_mark` en snake_case sur le fil). `process()` les
  extrait meme pour une locale pas encore entrainee.
- Tests `test_temperature_extraction.py` (10) ; `test_routes_process.py` :
  2 assertions d'egalite stricte gagnent `"temperatures": []`, +1 cas.

apps/api :
- `IntentServiceTemperature` (mappe `gas_mark` -> `gasMark` a la frontiere).
- `TechStepMatch.temperatures` : filtrees par appartenance de span a la
  clause, meme regle que les ustensiles.
- `model StepTechStepTemperature` (aucune FK — la valeur structuree EST la
  donnee) + migration manuelle `20260829120000_step_tech_step_temperature`
  (cascade via le TRUNCATE de `step_tech_step`, rien a ajouter a
  reset-db.ts). Persistance + lecture dans `recipe.service.ts`
  (`recipeInclude`, `toStepTechStepViews`) et l'include de sequence fraiche
  du service de correction ; `sources.service.ts` (apercu d'import) les
  fait transiter.

packages/shared : `StepTechStepTemperatureView` + `temperatures` sur
`StepTechStepView`.

apps/web :
- `splitDescriptionSegments` enrobe `splitDescriptionByTechSteps` et
  redecoupe les segments non-keyword autour des spans de temperature (la
  logique technique intriquee reste intacte). `?? []` tolere une payload
  d'avant `temperatures`.
- `StepDescription` : surlignage `.step-temperature` + Tooltip via
  `temperatureLabel` ; i18n `recipes.temperature.*`.
- Tests composants +4 (22 verts) ; pas d'UI de correction (v1, comme les
  ustensiles).

Verifie : biome + tsc + `pnpm -r build` ; web 102/103 e2e (l'echec est le
flake pre-existant recipe-form.feature, sans rapport) + 49/49 composants ;
pytest temperature + routes 15/15. `pnpm --filter api test` (Postgres +
intent-service requis) non lance ici.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 19:29:03 +02:00
24 changed files with 1113 additions and 26 deletions

View file

@ -56,7 +56,7 @@ cp apps/api/.env.example apps/api/.env
cp apps/web/.env.example apps/web/.env
```
Puis **édite ces deux `.env`** pour renseigner de vrais `POSTGRES_USER`/`POSTGRES_PASSWORD`
Puis **édite ces `.env`** pour renseigner de vrais `POSTGRES_USER`/`POSTGRES_PASSWORD`
(et la `DATABASE_URL` correspondante dans `apps/api/.env`) : les fichiers `.env.example`
ne contiennent volontairement aucun identifiant réel (juste `changeme`), et
`docker-compose.yml` refuse de démarrer tant que `POSTGRES_USER`/`PASSWORD`/`DB` ne
@ -64,6 +64,13 @@ sont pas définis dans `.env` — pas de valeur par défaut en dur dans les fich
Même règle pour `apps/api/.env` : `JWT_SECRET` est **requis, sans défaut** (génère le
tien, voir le commentaire dans `apps/api/.env.example`).
> **Toutes les variables d'environnement** (lesquelles poser, dans quel fichier,
> obligatoire ou non, à quoi elles servent, quels secrets doivent correspondre)
> sont listées et expliquées dans **[specs/environment.md](specs/environment.md)**.
> Le service NLP (`services/tech-step-intent-service`) et, si tu le lances, le
> worker LLM (`services/tech-step-llm-worker`) ont chacun leur propre `.env` à
> copier — voir ce document.
Si tu comptes lancer `pnpm --filter api test` (voir [Qualité / Tests](#qualité--tests)),
crée aussi `apps/api/.env.test` — voir la section dédiée plus bas.

View file

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

View file

@ -810,6 +810,9 @@ model StepTechStep {
/// Utensils mentioned in the same clause as this technique occurrence —
/// 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:

View file

@ -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;
}

View file

@ -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,
});
}

View file

@ -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,
},
});

View file

@ -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,
})),
},
})),
},
})),

View file

@ -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,
})),
},
]
: [];

View file

@ -285,6 +285,18 @@ describe("tech-step-matcher", () => {
contextEnd: text.length,
ingredients: [],
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");
@ -341,6 +353,7 @@ describe("tech-step-matcher", () => {
// (see `IntentServiceEntity.kind`).
ingredients: [],
utensils: [{ utensilId: panId, start: 9, end: 14 }],
temperatures: [],
});
expect(result[1]).to.deep.equal({
techStepId: meltId,
@ -357,6 +370,7 @@ describe("tech-step-matcher", () => {
{ ingredientId: butterId, start: 50, end: 56, quantity: null, unitId: null },
],
utensils: [],
temperatures: [],
});
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(
@ -387,6 +401,7 @@ describe("tech-step-matcher", () => {
{ ingredientId: butterId, start: 18, end: 24, quantity: null, unitId: null },
],
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 },
],
utensils: [],
temperatures: [],
},
]);
expect(text.slice(0, 4)).to.equal("Chop");

View file

@ -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");

View file

@ -123,6 +123,7 @@ describe("Recipe tech-step corrections", () => {
source: "manual",
ingredients: [],
utensils: [],
temperatures: [],
},
]);
});
@ -156,6 +157,7 @@ describe("Recipe tech-step corrections", () => {
source: "manual",
ingredients: [],
utensils: [],
temperatures: [],
},
]);
});
@ -319,6 +321,7 @@ describe("Recipe tech-step corrections", () => {
source: "manual",
},
],
temperatures: [],
},
]);
expect(res.body.techSteps[0].ingredients[0].ingredient.id).to.equal(butterId);

View file

@ -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);
});
});

View file

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

View file

@ -1,11 +1,38 @@
import type { StepTechStepView, SubmitTechStepCorrectionResult } from "@batch-cooking/shared";
import type {
StepTechStepTemperatureView,
StepTechStepView,
SubmitTechStepCorrectionResult,
} from "@batch-cooking/shared";
import { Fragment, useEffect, useRef, useState } from "react";
import { 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

View file

@ -1,4 +1,4 @@
import type { StepTechStepView } from "@batch-cooking/shared";
import type { StepTechStepTemperatureView, StepTechStepView } from "@batch-cooking/shared";
/**
* One run of a step's `description` either plain text, or part of a
@ -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;
}

View file

@ -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",

View file

@ -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";
}
/**

View file

@ -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
)

View file

@ -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,
)

View file

@ -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] = []
# ---------------------------------------------------------------------------

View file

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

View file

@ -22,7 +22,7 @@ def test_process_against_an_unsupported_locale_returns_empty_result(client: Test
"/v1/process", headers=_HEADERS, json={"locale": "de", "text": "faire mijoter à feu doux"}
)
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
)

View file

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

249
specs/environment.md Normal file
View file

@ -0,0 +1,249 @@
# Variables d'environnement
Référence unique de **toutes** les variables d'environnement de l'appli :
lesquelles poser, dans quel fichier, obligatoire ou non, et à quoi elles
servent.
La **source de vérité** de chaque variable reste son schéma de validation
(`apps/api/src/config/env.ts`, `services/tech-step-llm-worker/src/config.ts`,
`services/tech-step-intent-service/intent_service/config.py`) : au démarrage,
un secret requis manquant fait **échouer immédiatement** le process plutôt que
de laisser une erreur obscure survenir plus tard. Ce document résume ces
schémas et les relie entre eux.
---
## Principes
- **Aucun secret n'est commité.** Les fichiers `*.env.example` ne contiennent
que des `changeme` ; les vrais `.env` sont git-ignorés et à créer par copie.
- **Générer un secret** (32 caractères minimum) :
```bash
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
```
- **Deux workflows, deux jeux de fichiers :**
| Workflow | Fichiers lus |
|---|---|
| Dev natif (`pnpm dev:api` / `pnpm dev:web` + services lancés à la main) | `apps/api/.env`, `apps/web/.env`, `services/*/.env` |
| `pnpm --filter api test` | `apps/api/.env.test` (chargé à la place de `.env` via `NODE_ENV=test`) |
| Docker Compose (`docker compose …`, prod/review) | `.env` à la racine uniquement — Compose injecte le reste en `environment:` |
- **Secrets partagés :** plusieurs variables portent le même secret des deux
côtés d'un lien (API ↔ service). Elles **doivent avoir la même valeur**
voir [Secrets à faire correspondre](#secrets-à-faire-correspondre).
---
## Fichiers `.env`
| Fichier | Créé par | Utilisé pour | Modèle |
|---|---|---|---|
| `.env` (racine) | `cp .env.example .env` | **Docker Compose uniquement** (provisionne Postgres + injecte les vars des services) | `.env.example` |
| `apps/api/.env` | `cp apps/api/.env.example apps/api/.env` | API en dev natif (`pnpm dev:api`) | `apps/api/.env.example` |
| `apps/api/.env.test` | `cp apps/api/.env.test.example apps/api/.env.test` | `pnpm --filter api test` (base **séparée** de la dev) | `apps/api/.env.test.example` |
| `apps/web/.env` | `cp apps/web/.env.example apps/web/.env` | Front en dev natif — lu **au build** par Vite | `apps/web/.env.example` |
| `services/tech-step-intent-service/.env` | `cp …/.env.example …/.env` | Service NLP en dev natif | idem |
| `services/tech-step-llm-worker/.env` | `cp …/.env.example …/.env` | Worker LLM en dev natif (**optionnel**) | idem |
| `services/tech-step-llm-worker/.env.test` | `cp …/.env.test.example …/.env.test` | Tests Mocha du worker | idem |
---
## Postgres — `.env` racine (Docker Compose)
Provisionnent le conteneur `postgres` de `docker-compose.yml`. En dev natif,
ces valeurs ne sont lues que par Compose pour lancer la base ; l'API, elle,
lit `DATABASE_URL` dans `apps/api/.env` (qui doit refléter ces identifiants).
| Variable | Obligatoire | Défaut | Rôle |
|---|---|---|---|
| `POSTGRES_USER` | **oui** (Compose refuse de démarrer sans) | — | Utilisateur du conteneur Postgres |
| `POSTGRES_PASSWORD` | **oui** | — | Mot de passe |
| `POSTGRES_DB` | **oui** | — | Nom de la base |
| `POSTGRES_PORT` | non | `5432` | Port hôte exposé. Passer à `5433` si un Postgres natif occupe déjà `5432` (adapter aussi `DATABASE_URL`) |
---
## API — `apps/api/.env` (dev natif) / injecté par Compose
Schéma : `apps/api/src/config/env.ts`.
| Variable | Obligatoire | Défaut | Rôle |
|---|---|---|---|
| `NODE_ENV` | non | `development` | `development` \| `test` \| `production`. `test` déclenche des comportements dédiés (coût argon2 réduit, chargement de `.env.test`). |
| `PORT` | non | `3000` | Port d'écoute HTTP de l'API. |
| `DATABASE_URL` | **de fait oui** (Prisma en a besoin) | — | Chaîne de connexion Postgres. Doit refléter les identifiants du `.env` racine. En Compose elle est construite automatiquement et cible l'hôte `postgres:5432`. |
| `JWT_SECRET` | **oui, sans défaut** | — | Signe/vérifie le JWT de session utilisateur. ≥ 32 caractères. |
| `JWT_EXPIRES_IN` | non | `7d` | Durée de validité du JWT (format `jsonwebtoken`). |
| `AUTH_COOKIE_NAME` | non | `session` | Nom du cookie httpOnly de session utilisateur. |
| `CORS_ORIGIN` | non | `http://localhost:5173` | Origine autorisée par CORS — doit correspondre à l'URL de `apps/web`. En Compose, le front est servi par le **même** conteneur : pas de CORS cross-origin. |
| `COOKIE_SECURE` | non | *(non posé → `NODE_ENV === "production"`)* | Force l'attribut `Secure` du cookie de session. Mettre `false` **uniquement** si le déploiement est en HTTP nu (sans TLS devant) : sinon le cookie n'est jamais renvoyé et toute requête authentifiée renvoie 401 après un login pourtant réussi. |
| `FRONTEND_DIST_DIR` | non | — | Chemin absolu vers `apps/web/dist` à servir avec l'API. Posé **uniquement** dans l'image Docker de prod ; laissé vide en dev natif (c'est le serveur Vite de `pnpm dev:web` qui sert le front). |
### Lien API ↔ service NLP (`tech-step-intent-service`)
| Variable | Obligatoire | Défaut | Rôle |
|---|---|---|---|
| `INTENT_SERVICE_BASE_URL` | non | `http://localhost:8000` | URL du service NLP. Compose la remplace par le nom de service réseau (`http://tech-step-intent-service:8000`). |
| `INTENT_SERVICE_SECRET` | **oui, sans défaut** | — | Secret partagé (header `X-Intent-Service-Secret`). **Dépendance cœur** : sans le service NLP joignable, aucune technique de cuisine n'est détectée à l'enregistrement/l'aperçu d'une recette. Doit être **identique** à celui du service ([voir plus bas](#service-nlp--servicestech-step-intent-serviceenv)). |
### Surface d'administration `/admin/*` (optionnelle)
L'UI admin est servie par `apps/web` sous `/admin/*` (même origine que le
reste du front — pas de CORS dédié), mais avec une **auth totalement
séparée** de celle des utilisateurs.
| Variable | Obligatoire | Défaut | Rôle |
|---|---|---|---|
| `ADMIN_JWT_SECRET` | non *(mais `/admin/*` renvoie 401 tant qu'absent)* | — | Signe/vérifie le JWT de session admin. **Doit être ≠ `JWT_SECRET`** pour qu'un token utilisateur ne puisse jamais être rejoué contre `/admin/*`. ≥ 32 caractères. |
| `ADMIN_COOKIE_NAME` | non | `admin_session` | Nom du cookie httpOnly de session admin — doit différer de `AUTH_COOKIE_NAME` pour que les deux sessions coexistent dans un même navigateur. |
| `ADMIN_INITIAL_EMAIL` | non | — | Valeurs lues **uniquement** par `apps/api/src/scripts/create-admin.ts` quand ses flags `--email` / `--password` / `--name` sont omis (bootstrap du premier admin). Jamais lues par le serveur. |
| `ADMIN_INITIAL_PASSWORD` | non | — | idem |
| `ADMIN_INITIAL_NAME` | non | — | idem |
### Lien API ↔ worker LLM (`tech-step-llm-worker`, optionnel)
| Variable | Obligatoire | Défaut | Rôle |
|---|---|---|---|
| `INTERNAL_WORKER_SECRET` | non *(mais `/internal/tech-steps/*` renvoie 401 tant qu'absent)* | — | Secret partagé (header `X-Internal-Worker-Secret`) pour les appels du worker LLM vers l'API. ≥ 32 caractères. Job de fond **optionnel** : un déploiement qui ne lance pas le worker n'en a pas besoin. Doit être **identique** à celui du worker. |
---
## Front — `apps/web/.env`
Vite n'expose au code client **que** les variables préfixées `VITE_`, et les
inline **au build** (pas de lecture à l'exécution).
| Variable | Obligatoire | Défaut | Rôle |
|---|---|---|---|
| `VITE_API_URL` | non | `""` (même origine) | Base URL de l'API pour `fetch` (`apiClient` **et** `adminApiClient`). En dev natif : `http://localhost:3000`. Vide = même origine, correct derrière un reverse-proxy partagé ou dans l'image Docker mono-conteneur. |
*(La version affichée dans l'UI (`__APP_VERSION__`) vient de `package.json`
via `vite.config.ts`, ce n'est pas une variable d'environnement.)*
---
## Service NLP — `services/tech-step-intent-service/.env`
Schéma : `intent_service/config.py` (`pydantic-settings`). En Docker, Compose
injecte les variables ; en dev natif, elles viennent de ce `.env`.
| Variable | Obligatoire | Défaut | Rôle |
|---|---|---|---|
| `INTENT_SERVICE_SECRET` | **oui, sans défaut** (le service refuse de démarrer sans) | — | Secret attendu sur `X-Intent-Service-Secret` de chaque requête (sauf `GET /health`). Doit être **identique** à `INTENT_SERVICE_SECRET` côté API. |
| `LOG_LEVEL` | non | `INFO` | Niveau du logging JSON structuré. À `INFO`, chaque appel `/v1/process` journalise son input/output (locale, texte, entités, intent, score). |
Le **port** n'est pas une variable d'env : il est passé à `uvicorn` en
argument (`--port 8000`).
---
## Worker LLM — `services/tech-step-llm-worker/.env` (optionnel)
Schéma : `src/config.ts`. Nécessaire **uniquement** hors Docker Compose
(Compose pose lui-même `API_BASE_URL` / `INTERNAL_WORKER_SECRET`).
| Variable | Obligatoire | Défaut | Rôle |
|---|---|---|---|
| `INTERNAL_WORKER_SECRET` | **oui, sans défaut** | — | Doit être **identique** à `INTERNAL_WORKER_SECRET` côté API. ≥ 32 caractères. |
| `API_BASE_URL` | non | `http://app:3000` | Base URL de l'API. Le défaut est le nom d'hôte Compose du service `app` ; à surcharger (`http://localhost:3000`) pour un `pnpm dev:api` local. |
| `TECH_STEP_WORKER_CRON` | non | `0 3 * * 0` (dimanche 03:00) | Expression cron (syntaxe `node-cron`) de réveil du worker. Valeur provisoire à calibrer une fois déployé. |
| `TECH_STEP_LLM_MODEL_URI` | non | `hf:Qwen/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M` | URI `hf:<repo>:<quant>` du modèle GGUF à télécharger. |
| `TECH_STEP_LLM_MODEL_PATH` | non | — | Chemin local explicite vers un GGUF, court-circuite le téléchargement HF ci-dessus (offline / éviter un download en plein déploiement). |
| `TECH_STEP_WORKER_LOCALE` | non | `fr` | Locale échantillonnée par le job `audit-low-confidence`. |
| `TECH_STEP_WORKER_BATCH_LIMIT` | non | `50` | Borne (`?limit=`) sur le nombre d'items traités par run — plafonne le coût d'inférence LLM d'une exécution planifiée. |
| `RUN_ONCE` | non | *(non posé → `false`)* | `true` = lance les deux jobs une fois puis quitte, au lieu de démarrer la boucle cron (run manuel / CI). Comparaison de chaîne stricte : `"false"` vaut bien `false`. |
`services/tech-step-llm-worker/.env.test` ne contient qu'un
`INTERNAL_WORKER_SECRET` bidon (les tests du worker mockent tous les appels
HTTP vers l'API) — juste là pour satisfaire le schéma à l'import.
---
## Compose — `.env` racine, variables spécifiques
| Variable | Obligatoire | Défaut | Rôle |
|---|---|---|---|
| `APP_PORT` | non | `3000` | Port hôte du conteneur `app` (API + front construit) dans `docker-compose.yml`. |
| `COOKIE_SECURE` | non | *(non posé)* | Voir la table API — `${COOKIE_SECURE:-}` dans Compose, à mettre à `false` seulement pour un déploiement HTTP nu. |
| `TECH_STEP_WORKER_CRON` | non | `0 3 * * 0` | Passé au service `tech-step-llm-worker` de Compose. |
Le `.env` racine porte aussi `JWT_SECRET`, `INTENT_SERVICE_SECRET`,
`INTERNAL_WORKER_SECRET`, `ADMIN_JWT_SECRET`, `ADMIN_INITIAL_*` : en Compose,
c'est **là** qu'ils sont posés (et non dans `apps/api/.env`, jamais copié dans
l'image).
---
## Secrets à faire correspondre
Un même secret doit porter la **même valeur** aux deux bouts :
| Secret | Bout A | Bout B | Si absent / divergent |
|---|---|---|---|
| `INTENT_SERVICE_SECRET` | `apps/api/.env` | `services/tech-step-intent-service/.env` | Le service NLP refuse chaque requête → **plus aucune détection de technique** (dépendance cœur). |
| `INTERNAL_WORKER_SECRET` | `apps/api/.env` | `services/tech-step-llm-worker/.env` | `/internal/tech-steps/*` renvoie 401 → le worker LLM ne peut rien faire (job optionnel). |
| Identifiants Postgres | `.env` racine (`POSTGRES_*`) | `apps/api/.env` (`DATABASE_URL`) | `P1000 Authentication failed`. |
Contraintes supplémentaires :
- `ADMIN_JWT_SECRET` **≠** `JWT_SECRET` (isolation des sessions admin/utilisateur).
- `ADMIN_COOKIE_NAME` **≠** `AUTH_COOKIE_NAME`.
- `apps/api/.env.test``DATABASE_URL` doit pointer une base **différente** de
`apps/api/.env` (la suite `TRUNCATE` tout avant chaque test ; un garde-fou
refuse de tourner si l'URL ne contient ni `test` ni `ci`).
---
## Mise en route rapide
### Dev natif
```bash
cp .env.example .env # Postgres (Compose)
cp apps/api/.env.example apps/api/.env # API
cp apps/web/.env.example apps/web/.env # front
cp services/tech-step-intent-service/.env.example \
services/tech-step-intent-service/.env # service NLP
```
Puis éditer :
1. `.env` — vrais `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB`.
2. `apps/api/.env``DATABASE_URL` avec ces mêmes identifiants ; un
`JWT_SECRET` généré ; le **même** `INTENT_SERVICE_SECRET` que…
3. `services/tech-step-intent-service/.env` — …ce fichier.
4. `apps/web/.env``VITE_API_URL=http://localhost:3000` (déjà le défaut du
modèle).
Optionnel — pour l'admin : ajouter `ADMIN_JWT_SECRET` (≥ 32 c, ≠ `JWT_SECRET`)
dans `apps/api/.env`, puis créer un admin :
```bash
pnpm --filter api exec tsx src/scripts/create-admin.ts \
--email=ops@example.com --password='…' --name='Ops'
```
Optionnel — pour le worker LLM : ajouter le **même** `INTERNAL_WORKER_SECRET`
dans `apps/api/.env` **et** `services/tech-step-llm-worker/.env`.
### Tests `apps/api`
```bash
cp apps/api/.env.test.example apps/api/.env.test
```
Éditer `DATABASE_URL` → base **dédiée** (ex. `batchcooking_test`), même
`JWT_SECRET` / `INTENT_SERVICE_SECRET` que d'habitude. Le service NLP doit
tourner. Détails : `apps/api/.env.test.example`.
### Docker Compose
Un seul fichier : `.env` à la racine. Y renseigner `POSTGRES_*`, `JWT_SECRET`,
`INTENT_SERVICE_SECRET`, et — si besoin — `ADMIN_JWT_SECRET`,
`INTERNAL_WORKER_SECRET`, `COOKIE_SECURE=false` (HTTP nu), `APP_PORT`.
```bash
docker compose up -d postgres # dev : Postgres seul
docker compose up -d --build # stack complète (review/prod)
```