Compare commits
1 commit
feat/merge
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d5dca82af4 |
24 changed files with 26 additions and 1113 deletions
|
|
@ -56,7 +56,7 @@ cp apps/api/.env.example apps/api/.env
|
|||
cp apps/web/.env.example apps/web/.env
|
||||
```
|
||||
|
||||
Puis **édite ces `.env`** pour renseigner de vrais `POSTGRES_USER`/`POSTGRES_PASSWORD`
|
||||
Puis **édite ces deux `.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,13 +64,6 @@ 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
-- 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,9 +810,6 @@ 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")
|
||||
|
|
@ -871,38 +868,6 @@ 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,28 +25,11 @@ export interface IntentServiceEntity {
|
|||
kind: "technique" | "utensil";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). */
|
||||
/** 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). */
|
||||
export interface IntentServiceProcessResult {
|
||||
entities: IntentServiceEntity[];
|
||||
intent: string | null;
|
||||
score: number;
|
||||
temperatures: IntentServiceTemperature[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -105,26 +88,10 @@ export class IntentServiceClient {
|
|||
*/
|
||||
public async process(locale: string, text: string): Promise<IntentServiceProcessResult> {
|
||||
try {
|
||||
// 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", {
|
||||
return await this._request("/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 { type IntentServiceTemperature, intentServiceClient } from "./intent-service-client.js";
|
||||
import { intentServiceClient } from "./intent-service-client.js";
|
||||
|
||||
/**
|
||||
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe
|
||||
|
|
@ -114,7 +114,6 @@ export interface TechStepMatch {
|
|||
contextEnd: number;
|
||||
ingredients: IngredientMention[];
|
||||
utensils: UtensilMention[];
|
||||
temperatures: TemperatureMention[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -130,15 +129,6 @@ 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. */
|
||||
|
|
@ -418,7 +408,6 @@ 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[] = [];
|
||||
|
|
@ -453,12 +442,6 @@ 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,
|
||||
|
|
@ -467,7 +450,6 @@ export class TechStepClassifierService {
|
|||
contextEnd: clause.end,
|
||||
ingredients,
|
||||
utensils,
|
||||
temperatures,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -446,11 +446,10 @@ export async function submitTechStepCorrection(
|
|||
},
|
||||
);
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
const freshTechSteps = await tx.stepTechStep.findMany({
|
||||
where: { stepId: step.id },
|
||||
orderBy: { order: "asc" },
|
||||
|
|
@ -468,7 +467,6 @@ export async function submitTechStepCorrection(
|
|||
},
|
||||
},
|
||||
utensils: { include: { utensil: true } },
|
||||
temperatures: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,6 @@ function recipeInclude(viewerId: number) {
|
|||
},
|
||||
},
|
||||
utensils: { include: { utensil: true } },
|
||||
temperatures: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -176,17 +175,8 @@ export function toStepTechStepViews(
|
|||
): StepTechStepView[] {
|
||||
const views: StepTechStepView[] = [];
|
||||
for (const stepTechStep of techSteps) {
|
||||
const {
|
||||
start,
|
||||
end,
|
||||
contextStart,
|
||||
contextEnd,
|
||||
techStep,
|
||||
source,
|
||||
ingredients,
|
||||
utensils,
|
||||
temperatures,
|
||||
} = stepTechStep;
|
||||
const { start, end, contextStart, contextEnd, techStep, source, ingredients, utensils } =
|
||||
stepTechStep;
|
||||
if (start === null || end === null) continue;
|
||||
views.push({
|
||||
techStep: { id: techStep.id, key: techStep.key },
|
||||
|
|
@ -216,24 +206,6 @@ 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;
|
||||
|
|
@ -718,16 +690,6 @@ 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,12 +260,6 @@ 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,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
|
|
|||
|
|
@ -285,18 +285,6 @@ 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");
|
||||
|
|
@ -353,7 +341,6 @@ describe("tech-step-matcher", () => {
|
|||
// (see `IntentServiceEntity.kind`).
|
||||
ingredients: [],
|
||||
utensils: [{ utensilId: panId, start: 9, end: 14 }],
|
||||
temperatures: [],
|
||||
});
|
||||
expect(result[1]).to.deep.equal({
|
||||
techStepId: meltId,
|
||||
|
|
@ -370,7 +357,6 @@ 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(
|
||||
|
|
@ -401,7 +387,6 @@ describe("tech-step-matcher", () => {
|
|||
{ ingredientId: butterId, start: 18, end: 24, quantity: null, unitId: null },
|
||||
],
|
||||
utensils: [{ utensilId: panId, start: 45, end: 50 }],
|
||||
temperatures: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -420,7 +405,6 @@ describe("tech-step-matcher", () => {
|
|||
{ ingredientId: onionId, start: 9, end: 15, quantity: null, unitId: null },
|
||||
],
|
||||
utensils: [],
|
||||
temperatures: [],
|
||||
},
|
||||
]);
|
||||
expect(text.slice(0, 4)).to.equal("Chop");
|
||||
|
|
|
|||
|
|
@ -404,42 +404,6 @@ 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");
|
||||
|
|
|
|||
|
|
@ -123,7 +123,6 @@ describe("Recipe tech-step corrections", () => {
|
|||
source: "manual",
|
||||
ingredients: [],
|
||||
utensils: [],
|
||||
temperatures: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -157,7 +156,6 @@ describe("Recipe tech-step corrections", () => {
|
|||
source: "manual",
|
||||
ingredients: [],
|
||||
utensils: [],
|
||||
temperatures: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -321,7 +319,6 @@ describe("Recipe tech-step corrections", () => {
|
|||
source: "manual",
|
||||
},
|
||||
],
|
||||
temperatures: [],
|
||||
},
|
||||
]);
|
||||
expect(res.body.techSteps[0].ingredients[0].ingredient.id).to.equal(butterId);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import type { StepTechStepTemperatureView, StepTechStepView } from "@batch-cooking/shared";
|
||||
import {
|
||||
splitDescriptionByTechSteps,
|
||||
splitDescriptionSegments,
|
||||
} from "../../src/features/recipes/steps/highlight-tech-steps";
|
||||
import type { StepTechStepView } from "@batch-cooking/shared";
|
||||
import { splitDescriptionByTechSteps } 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
|
||||
|
|
@ -251,82 +248,3 @@ 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,34 +696,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
// 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,38 +1,11 @@
|
|||
import type {
|
||||
StepTechStepTemperatureView,
|
||||
StepTechStepView,
|
||||
SubmitTechStepCorrectionResult,
|
||||
} from "@batch-cooking/shared";
|
||||
import type { 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 { splitDescriptionSegments } from "./highlight-tech-steps";
|
||||
import { splitDescriptionByTechSteps } 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
|
||||
|
|
@ -93,19 +66,7 @@ export function StepDescription({
|
|||
const [liveTechSteps, setLiveTechSteps] = useState(techSteps);
|
||||
useEffect(() => setLiveTechSteps(techSteps), [techSteps]);
|
||||
|
||||
// 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 segments = splitDescriptionByTechSteps(description, liveTechSteps);
|
||||
const containerRef = useRef<HTMLParagraphElement>(null);
|
||||
const { getSelectionRange } = useTextSelection(containerRef);
|
||||
|
||||
|
|
@ -208,26 +169,6 @@ 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 { StepTechStepTemperatureView, StepTechStepView } from "@batch-cooking/shared";
|
||||
import type { StepTechStepView } from "@batch-cooking/shared";
|
||||
|
||||
/**
|
||||
* One run of a step's `description` — either plain text, or part of a
|
||||
|
|
@ -20,15 +20,6 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -148,88 +139,3 @@ 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,15 +166,6 @@
|
|||
"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,11 +57,10 @@ export interface RecipeIngredientView {
|
|||
* `applyManualCorrection`). `StepDescription.tsx` renders the two with a
|
||||
* different highlight color so a viewer can tell which is which.
|
||||
*
|
||||
* `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.
|
||||
* `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.
|
||||
*/
|
||||
export interface StepTechStepView {
|
||||
techStep: TechStepView;
|
||||
|
|
@ -72,36 +71,6 @@ 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,7 +31,6 @@ 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__)
|
||||
|
|
@ -191,9 +190,6 @@ 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):
|
||||
|
|
@ -419,15 +415,8 @@ 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, temperatures=temperatures
|
||||
)
|
||||
return ProcessResult(entities=[], intent=None, score=0.0)
|
||||
|
||||
doc = self._base_nlp(text)
|
||||
|
||||
|
|
@ -487,10 +476,6 @@ class LocalePipeline:
|
|||
|
||||
cats = doc.cats
|
||||
if not cats:
|
||||
return ProcessResult(
|
||||
entities=entities, intent=None, score=0.0, temperatures=temperatures
|
||||
)
|
||||
return ProcessResult(entities=entities, intent=None, score=0.0)
|
||||
intent = max(cats, key=cats.get)
|
||||
return ProcessResult(
|
||||
entities=entities, intent=intent, score=cats[intent], temperatures=temperatures
|
||||
)
|
||||
return ProcessResult(entities=entities, intent=intent, score=cats[intent])
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import logging
|
|||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..pipeline_registry import registry
|
||||
from ..schemas import EntityPayload, ProcessRequest, ProcessResponse, TemperaturePayload
|
||||
from ..schemas import EntityPayload, ProcessRequest, ProcessResponse
|
||||
from ..security import require_valid_secret
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -34,16 +34,6 @@ 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,
|
||||
},
|
||||
|
|
@ -54,17 +44,6 @@ 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,27 +39,10 @@ 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] = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,161 +0,0 @@
|
|||
"""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, "temperatures": []}
|
||||
assert response.json() == {"entities": [], "intent": None, "score": 0.0}
|
||||
|
||||
|
||||
def test_process_returns_entities_and_intent_for_a_real_corpus_sentence(client: TestClient):
|
||||
|
|
@ -48,27 +48,4 @@ 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, "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
|
||||
)
|
||||
assert response.json() == {"entities": [], "intent": None, "score": 0.0}
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
"""`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
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
# 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)
|
||||
```
|
||||
Loading…
Reference in a new issue