Compare commits

..

1 commit

Author SHA1 Message Date
42af7e6dbb fix(recipes): active le matching ingredients/unites pour les sources en francais
Cause racine du signalement "beaucoup d'ingredients ne sont pas linkes,
de meme pour les unites et les quantites" sur Marmiton/750g/Manger
Bouger : translateRecipe (recipe-translation.ts) ET previewSourceItem
(sources.service.ts) sautaient integralement loadIngredientCatalog/
loadUnitCatalog/translateRecipeIngredients des que locale !== "en" —
aucune tentative de matching n'etait jamais faite pour une source
francaise, pas un probleme de qualite de matching. Les trois sources
ajoutees dans cette session sont toutes locale: "fr".

Corrige en trois temps :

- packages/shared/src/data/catalog-labels-fr.ts (nouveau) :
  INGREDIENT_LABELS_FR (554 entrees, copiees depuis
  apps/web/src/locales/fr/translation.json qui les avait deja pour
  l'UI — pas une nouvelle redaction), INGREDIENT_LABEL_SYNONYMS_FR
  (mecanisme existant, pour patcher au cas par cas les libelles dont le
  phrasage "affichage" ne correspond pas a l'ordre naturel d'un texte
  de recette — ex. vanillaBean), UNIT_LABELS_FR (17 entrees,
  redigees a la main comme UNIT_LABELS_EN — abreviations/variantes
  reellement utilisees en francais : cuillere a soupe/cas/c.a.s...).

- ingredient-matcher.ts : stemWord se scinde en stemWordEn/stemWordFr
  (locale parametrable, defaut "en" pour ne rien casser) — le stemmer
  anglais appliquait sa regle "es" -> "" a des pluriels francais
  reguliers ("carottes" -> "carott" au lieu de "carotte"), cassant
  silencieusement le matching pour la quasi-totalite des ingredients
  francais dont le singulier se termine par une voyelle. matchUnit est
  reecrit pour chercher une sous-sequence ordonnee (comme
  matchIngredientName) plutot qu'une egalite du seul premier mot : un
  synonyme francais peut etre multi-mots ("cuillere a soupe"), une
  phrase entiere ne pouvant jamais egaler un seul mot extrait.
  loadIngredientCatalog/loadUnitCatalog prennent un parametre locale.

- recipe-translation.ts/sources.service.ts : suppression du
  if (locale !== "en") qui court-circuitait tout — les catalogues sont
  desormais toujours charges avec la locale de la source ; une locale
  sans table de libelles recoit simplement des catalogues vides (degrade
  gracieusement, ne plante pas).

Tests : 14 nouveaux tests purs (matchIngredientName/matchUnit fr,
stemmer, regression), 3 nouveaux tests DB (loadIngredientCatalog/
loadUnitCatalog fr + locale inconnue), 3 nouveaux tests
recipe-translation remplacant un test qui figeait l'ancien comportement
cassé, 1 nouveau test d'integration HTTP (sources.test.ts) avec un
adaptateur factice francais bout en bout. Les tests DB n'ont pas pu
etre executes localement (pas de Postgres/Docker dans cet environnement
sandbox) — a verifier en CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:18:58 +02:00
104 changed files with 2479 additions and 10502 deletions

View file

@ -22,14 +22,6 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# browser, so login "succeeds" but every subsequent request 401s.
# COOKIE_SECURE=false
# Required — secret shared between "app" and "tech-step-intent-service"
# (docker-compose.yml, apps/api/src/config/env.ts). Unlike
# INTERNAL_WORKER_SECRET below, there's no "leave it unset" escape hatch:
# tech-step-intent-service is a core dependency, not an optional background
# job — without it, no recipe step can have its techniques detected at all.
# Generate your own the same way as JWT_SECRET above.
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Only needed to run the optional `tech-step-llm-worker` service — shared
# between it and "app" (docker-compose.yml). Generate your own the same
# way as JWT_SECRET above; leave both this and the service commented

View file

@ -12,32 +12,26 @@ on:
push:
env:
DATABASE_URL: "postgresql://ci:ci@postgres:5432/batchcooking_ci?schema=public"
DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public"
# Test-only secret, never used outside CI — real deployments must set their own.
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
# Same reasoning as JWT_SECRET above — lets tech-step-worker.routes.test.ts
# exercise the success path (matching secret), not just the "unset"
# rejection every environment that doesn't set this gets by default.
INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+"
# Shared between the `test` job's own uvicorn step (below) and apps/api's
# IntentServiceClient — see the `test` job for why this can't be a
# `services:` container like postgres above (GitHub Actions can only pull
# a published image, not build services/tech-step-intent-service/Dockerfile).
INTENT_SERVICE_BASE_URL: "http://localhost:8000"
INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+"
jobs:
# Five independent jobs, no needs: between them — each starts in parallel
# Four independent jobs, no needs: between them — each starts in parallel
# and reports as its own check, instead of the previous single chained
# "lint-and-test then e2e" pipeline.
lint:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: actions/checkout@v4
- uses: https://github.com/pnpm/action-setup@v4
- uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
@ -55,80 +49,34 @@ jobs:
POSTGRES_PASSWORD: ci
POSTGRES_DB: batchcooking_ci
ports:
- 5433:5432
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: https://github.com/actions/checkout@v4
- uses: actions/checkout@v4
- uses: https://github.com/pnpm/action-setup@v4
- uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: https://github.com/astral-sh/setup-uv@v5
with:
python-version: "3.12"
enable-cache: true
# `services:` (like the `postgres` container above) can only pull an
# already-published image — it can't build
# services/tech-step-intent-service/Dockerfile from this checkout.
# Running `uvicorn` as a plain background step instead: it keeps
# running for the rest of this job (GitHub Actions steps in one job
# share the same runner process tree), and `pnpm --filter api test`
# below needs a real instance to talk to per this repo's "never mock
# an internal service" test convention — same reasoning as the real
# `postgres` container just above, not a mock HTTP server.
- name: Install services/tech-step-intent-service
working-directory: services/tech-step-intent-service
run: uv sync --frozen
- name: Start services/tech-step-intent-service in the background
working-directory: services/tech-step-intent-service
run: |
uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 &
# `/health` only returns 200 once this service has finished
# training itself from scratch (no model ever persisted to disk —
# see its own README) — measured at ~540s (fr) / ~390s (en),
# ~930s combined, against the current ~74-technique corpus (see
# docker-compose.yml's healthcheck for the same reasoning and why
# this grew slightly from the original ~670s).
timeout 1200 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done'
- run: pnpm install --frozen-lockfile
- run: pnpm --filter api exec prisma migrate deploy
- run: pnpm --filter api test
intent-service-test:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/astral-sh/setup-uv@v5
with:
python-version: "3.12"
enable-cache: true
- name: Install services/tech-step-intent-service
working-directory: services/tech-step-intent-service
run: uv sync --frozen
- name: Run pytest
working-directory: services/tech-step-intent-service
run: uv run pytest -q
build:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: actions/checkout@v4
- uses: https://github.com/pnpm/action-setup@v4
- uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
@ -139,17 +87,17 @@ jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: actions/checkout@v4
- uses: https://github.com/pnpm/action-setup@v4
- uses: pnpm/action-setup@v4
- uses: https://github.com/actions/setup-node@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Cache Cypress binary
uses: https://github.com/actions/cache@v4
uses: actions/cache@v4
with:
path: ~/.cache/Cypress
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}

11
.gitignore vendored
View file

@ -71,12 +71,11 @@ web_modules/
!.env.example
!.env.test.example
# Python virtualenvs/caches for services/tech-step-intent-service (this repo
# is otherwise all-Node — see that service's own .gitignore for the rest;
# duplicated here too since some tooling only honors the repo-root file).
services/tech-step-intent-service/.venv/
services/tech-step-intent-service/__pycache__/
services/tech-step-intent-service/.pytest_cache/
# node-nlp's default auto-save file (apps/api/src/lib/recipe-matching/
# tech-step-matcher.ts explicitly disables autoSave/autoLoad, but this is a
# belt-and-suspenders guard against it ever reappearing — a stale trained
# model on disk must never silently shadow TECH_STEP_TRAINING_DATA).
model.nlp
# parcel-bundler cache (https://parceljs.org/)
.cache

View file

@ -44,8 +44,6 @@ runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans
- Node.js 22 (voir `.nvmrc`)
- pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle)
- Docker (pour Postgres en local)
- Python 3.12+ et [`uv`](https://docs.astral.sh/uv/) (pour
`services/tech-step-intent-service` en dev natif — requis, voir plus bas)
## Installation
@ -102,17 +100,6 @@ pnpm --filter api exec prisma migrate dev
# techniques...) — automatique après `prisma migrate reset`, sinon à la main :
pnpm --filter api prisma:seed
# Microservice de détection des techniques (spaCy) — requis, `pnpm dev:api`
# ne peut plus détecter aucune technique de cuisine sans lui. Lance-le en
# premier et laisse-le tourner : il s'entraîne lui-même à chaque démarrage
# (~11 minutes pour le corpus actuel, voir son propre README) avant de
# répondre quoi que ce soit sur /health.
cd services/tech-step-intent-service
uv sync
cp .env.example .env # édite-le : même INTENT_SERVICE_SECRET que apps/api/.env
uv run uvicorn intent_service.main:app --reload --port 8000
cd ../..
# Backend (http://localhost:3000)
pnpm dev:api
@ -150,7 +137,7 @@ pnpm --filter web cy:run:component # tests de composant UI isolés (Cypress com
pnpm build # build de tous les workspaces
```
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute cinq jobs indépendants (`lint`, `test`, `intent-service-test`, `build`, `e2e` — ce dernier lance aussi `cy:run:component`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. `test` démarre `services/tech-step-intent-service` en arrière-plan (voir ce fichier) puisque la suite Mocha ne mocke jamais un service interne. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute quatre jobs indépendants (`lint`, `test`, `build`, `e2e` — ce dernier lance aussi `cy:run:component`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
### Base de test isolée de la base de dev (`apps/api`)
@ -171,13 +158,6 @@ Un garde-fou (`assertRunningAgainstTestDatabase()`) refuse d'exécuter
`resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la
seule base qu'il doit rejeter est ta vraie base de dev.
`services/tech-step-intent-service` doit aussi tourner en local avant
`pnpm --filter api test` — les tests touchant `tech-step-matcher.ts` passent
par le vrai service (jamais un mock, voir
[specs/dev-conventions.md](specs/dev-conventions.md)) et échouent avec une
erreur de connexion, pas une assertion utile, s'il n'est pas démarré. Voir la
section [Développement](#développement) ci-dessus.
## Déploiement
Une seule image Docker (`apps/api/Dockerfile`) sert à la fois l'API et le frontend
@ -200,12 +180,10 @@ synchronisation de la table `sources` depuis le registre d'adaptateurs de code
puis `node dist/server.js`. Les trois étapes sont sûres/idempotentes à
répéter à chaque redémarrage du conteneur.
Le duo `postgres`/`app` de `docker-compose.yml` n'expose donc qu'un seul port
applicatif, `APP_PORT` (défaut `3000`) — plus de `WEB_PORT`/`CORS_ORIGIN` à
coordonner entre deux origines, le frontend et l'API sont désormais servis
depuis la même origine. Les deux autres services du fichier
(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
au host — voir leurs propres README pour leur rôle.
`docker-compose.yml` ne définit donc que deux services : `postgres` et `app` (un
seul port, `APP_PORT`, défaut `3000` — plus de `WEB_PORT`/`CORS_ORIGIN` à
coordonner entre deux origines, le frontend et l'API sont désormais servis depuis
la même origine).
**Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
production est reliée directement au dépôt Git et reconstruit elle-même

View file

@ -12,13 +12,6 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# JWT_EXPIRES_IN=7d
# AUTH_COOKIE_NAME=session
# CORS_ORIGIN=http://localhost:5173
# INTENT_SERVICE_BASE_URL=http://localhost:8000
# Required — services/tech-step-intent-service must be running locally (see
# that service's own README) for any recipe save/preview to detect
# techniques at all. Must match that service's own INTENT_SERVICE_SECRET.
# Generate your own the same way as JWT_SECRET above.
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Only needed if you're running services/tech-step-llm-worker locally —
# every /internal/tech-steps/* request is rejected outright while unset.

View file

@ -14,15 +14,6 @@ DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking_test?sc
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Required — the Mocha suite exercises the real techStepClassifier, which
# now round-trips over HTTP to services/tech-step-intent-service (no mocks
# of internal services, per this repo's test conventions). Start that
# service locally first (see its own README) with a matching
# INTENT_SERVICE_SECRET, or every test touching tech-step-matcher.ts fails
# with a connection error rather than a useful assertion failure.
INTENT_SERVICE_BASE_URL=http://localhost:8000
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optional — only needed to exercise tech-step-worker.routes.test.ts's
# success path (a request with a matching secret); every other test runs
# fine without it. Any value at least 32 chars works locally.

View file

@ -2,6 +2,5 @@
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"node-option": ["import=tsx"],
"timeout": 10000,
"require": ["test-support/mocha-root-hooks.ts"]
"timeout": 10000
}

View file

@ -26,6 +26,7 @@
"dotenv": "^16.4.5",
"express": "^4.21.1",
"jsonwebtoken": "^9.0.3",
"node-nlp": "4.27.0",
"prisma": "^5.22.0",
"zod": "^3.23.8"
},

View file

@ -1,10 +0,0 @@
-- CreateTable
CREATE TABLE "utensil" (
"id" SERIAL NOT NULL,
"key" TEXT NOT NULL,
CONSTRAINT "utensil_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "utensil_key_key" ON "utensil"("key");

View file

@ -1,42 +0,0 @@
-- CreateTable
CREATE TABLE "step_tech_step_ingredient" (
"id" SERIAL NOT NULL,
"step_id" INTEGER NOT NULL,
"tech_step_order" INTEGER NOT NULL,
"ingredient_id" INTEGER NOT NULL,
"quantity" DECIMAL(10,2),
"unit_id" INTEGER,
"start" INTEGER NOT NULL,
"end" INTEGER NOT NULL,
"source" TEXT NOT NULL DEFAULT 'auto',
CONSTRAINT "step_tech_step_ingredient_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "step_tech_step_utensil" (
"id" SERIAL NOT NULL,
"step_id" INTEGER NOT NULL,
"tech_step_order" INTEGER NOT NULL,
"utensil_id" INTEGER NOT NULL,
"start" INTEGER NOT NULL,
"end" INTEGER NOT NULL,
"source" TEXT NOT NULL DEFAULT 'auto',
CONSTRAINT "step_tech_step_utensil_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_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;
-- AddForeignKey
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_unit_id_fkey" FOREIGN KEY ("unit_id") REFERENCES "unit"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_utensil" ADD CONSTRAINT "step_tech_step_utensil_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;
-- AddForeignKey
ALTER TABLE "step_tech_step_utensil" ADD CONSTRAINT "step_tech_step_utensil_utensil_id_fkey" FOREIGN KEY ("utensil_id") REFERENCES "utensil"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -521,9 +521,6 @@ model Ingredient {
dislikedBy UserProfileDislikedIngredient[]
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
diets IngredientDiet[]
/// Mentions of this ingredient detected in a step's free text alongside a
/// technique — see `StepTechStepIngredient`.
stepTechSteps StepTechStepIngredient[]
@@map("ingredients")
}
@ -600,12 +597,6 @@ model Unit {
toBaseFactor Decimal @default(1) @map("to_base_factor") @db.Decimal(12, 4)
recipeIngredients RecipeIngredient[]
/// Ingredient mentions detected alongside a technique in a step's free
/// text (e.g. "50g" resolved against this `Unit`) — see
/// `StepTechStepIngredient`. Distinct from `recipeIngredients` above
/// (the recipe's structured ingredient list): a step can mention a
/// quantity+unit that was never itself an ingredient list line.
stepTechStepIngredients StepTechStepIngredient[]
@@map("unit")
}
@ -636,13 +627,13 @@ model RecipeIngredient {
/// Matching a step's free text against these (`tech-step-matcher.ts`'s
/// `TechStepClassifierService`) used to go through a DB-backed
/// `TechStepMapping` table of per-locale regex expressions — replaced with
/// a spaCy-based model (`services/tech-step-intent-service`) trained from
/// in-code data (`tech-step-training-data.ts`) once regexes turned out
/// unable to generalize past their own literal vocabulary. Nothing
/// queries/edits that matching data at runtime anymore (it only ever feeds
/// that service's one-time training pass), so it no longer needs a table
/// of its own — this row now only exists to be a stable id/key other
/// tables (`StepTechStep`) reference.
/// a node-nlp model trained from in-code data
/// (`tech-step-training-data.ts`) once regexes turned out unable to
/// generalize past their own literal vocabulary. Nothing queries/edits
/// that matching data at runtime anymore (it only ever feeds the
/// classifier's one-time training pass), so it no longer needs a table of
/// its own — this row now only exists to be a stable id/key other tables
/// (`StepTechStep`) reference.
model TechStep {
id Int @id @default(autoincrement())
key String @unique
@ -661,25 +652,6 @@ model TechStep {
@@map("tech_step")
}
/// `key` is `@unique`, same bare id+key shape as `TechStep` — no
/// categorization taxonomy like `Ingredient` needed yet, and no matching
/// data of its own here either: unlike `TechStep` (whose matching synonyms
/// used to live in TS and were moved into
/// `services/tech-step-intent-service`'s `training_data.py`), this catalog
/// was *born* owned by that service (`utensil_vocabulary.py`) since nothing
/// pre-existing needed it — this row only exists to be a stable id/key
/// `StepTechStepUtensil` references, and to carry a French label
/// (`apps/web`'s `catalog.utensils.<key>`, see `reference-seed-data.ts`'s
/// `UTENSILS`).
model Utensil {
id Int @id @default(autoincrement())
key String @unique
steps StepTechStepUtensil[]
@@map("utensil")
}
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the
/// many-to-many noted in the spec doc: `order` only makes sense scoped to a
/// single recipe, which isn't reconcilable with steps being shared across
@ -750,70 +722,11 @@ model StepTechStep {
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
/// Ingredients mentioned in the same clause as this technique occurrence
/// — see `StepTechStepIngredient`.
ingredients StepTechStepIngredient[]
/// Utensils mentioned in the same clause as this technique occurrence —
/// see `StepTechStepUtensil`.
utensils StepTechStepUtensil[]
@@id([stepId, order])
@@map("step_tech_step")
}
/// An ingredient mention found in the same *clause* as one `StepTechStep`
/// occurrence (`tech-step-matcher.ts`'s `matchTechStepSpans` — clauses are
/// already the unit a technique is judged on, see that file's doc comment,
/// so "same clause" is the association rule, no dependency-parsing needed).
/// `quantity`/`unitId` are best-effort, populated only when a leading
/// numeric expression immediately preceding the ingredient mention resolved
/// against the `Unit` catalog (`ingredient-matcher.ts`'s
/// `findIngredientMentions`) — both `null` when the clause names the
/// ingredient with no quantity ("ajouter le sel"). `start`/`end` are the
/// ingredient mention's own span in `Step.description`, same `[start, end)`
/// convention as `StepTechStep.start`/`end`. `source` mirrors
/// `StepTechStep.source` (`"auto"` today, room for a future user
/// correction without a shape change).
model StepTechStepIngredient {
id Int @id @default(autoincrement())
stepId Int @map("step_id")
techStepOrder Int @map("tech_step_order")
ingredientId Int @map("ingredient_id")
quantity Decimal? @db.Decimal(10, 2)
unitId Int? @map("unit_id")
start Int
end Int
source String @default("auto")
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
unit Unit? @relation(fields: [unitId], references: [id])
@@map("step_tech_step_ingredient")
}
/// A utensil mention found in the same clause as one `StepTechStep`
/// occurrence — same association rule as `StepTechStepIngredient` (see its
/// doc comment). Detected by
/// `services/tech-step-intent-service`'s own utensil `PhraseMatcher`
/// (`intent_service/utensil_vocabulary.py`), returned alongside technique
/// entities in `POST /v1/process` and filtered to this clause's span by
/// `tech-step-matcher.ts`.
model StepTechStepUtensil {
id Int @id @default(autoincrement())
stepId Int @map("step_id")
techStepOrder Int @map("tech_step_order")
utensilId Int @map("utensil_id")
start Int
end Int
source String @default("auto")
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
utensil Utensil @relation(fields: [utensilId], references: [id], onDelete: Cascade)
@@map("step_tech_step_utensil")
}
/// 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

@ -13,7 +13,6 @@ import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
import { profileRouter } from "./modules/profile/profile.routes.js";
import { recipeRouter } from "./modules/recipe/recipe.routes.js";
import { referenceRouter } from "./modules/reference/reference.routes.js";
import { shoppingListRouter } from "./modules/shopping-list/shopping-list.routes.js";
import { sourcesRouter } from "./modules/sources/sources.routes.js";
/**
@ -51,7 +50,6 @@ export function createServer(): ExpressServer {
server.mountRouter("/profile", profileRouter);
server.mountRouter("/recipes", recipeRouter);
server.mountRouter("/reference", referenceRouter);
server.mountRouter("/shopping-list", shoppingListRouter);
server.mountRouter("/sources", sourcesRouter);
// Serves the built frontend (production Docker image only — see

View file

@ -71,25 +71,6 @@ const envSchema = z.object({
* fails closed rather than open if a real deployment forgets to set it.
*/
INTERNAL_WORKER_SECRET: z.string().min(32).optional(),
/**
* Base URL of `services/tech-step-intent-service` (the spaCy-based
* microservice `TechStepClassifierService` delegates NER + intent
* classification to, see `lib/recipe-matching/intent-service-client.ts`).
* Has a default (unlike `DATABASE_URL`/secrets below) since it isn't
* secret and dev natively runs it on a fixed local port Docker Compose
* overrides it to the compose network's service name.
*/
INTENT_SERVICE_BASE_URL: z.string().url().default("http://localhost:8000"),
/**
* Shared secret sent as an `X-Intent-Service-Secret` header on every call
* to `services/tech-step-intent-service`. Unlike `INTERNAL_WORKER_SECRET`
* above, **required, no `.optional()`** that service is a core
* dependency (recipe save/preview can no longer detect any technique
* without it), not an optional background job; an environment that
* forgets to set this must fail loudly at startup, not silently run with
* every technique detection request failing one at a time.
*/
INTENT_SERVICE_SECRET: z.string().min(32, "INTENT_SERVICE_SECRET must be at least 32 characters"),
});
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */

View file

@ -62,12 +62,11 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }>
//
// Just a flat list of stable ids here — the actual matching data (per-
// locale synonym lists + example phrasings the classifier trains on) lives
// in `services/tech-step-intent-service/intent_service/training_data.py`'s
// `TECH_STEP_TRAINING_DATA`, not here: it's owned and trained entirely by
// that separate Python service (see its own README), not read by this
// seed script at all, so it doesn't belong alongside the rest of this
// file's DB-seeded reference data. Every entry here must have a matching
// entry there.
// in `lib/recipe-matching/tech-step-training-data.ts`'s
// `TECH_STEP_TRAINING_DATA`, not here: unlike this list, it's read by
// `TechStepClassifierService`'s training pass, not the seed script, so it
// doesn't belong alongside the rest of this file's DB-seeded reference
// data. Every entry here must have a matching entry there.
export const TECH_STEPS: string[] = [
"cook",
"fry",
@ -95,100 +94,6 @@ export const TECH_STEPS: string[] = [
"bake",
"plate",
"coat",
// Lexique de techniques ajouté par la suite — voir
// `services/tech-step-intent-service/intent_service/training_data.py`
// pour les synonymes/phrases d'exemple de chacune.
"baste",
"appertize",
"whiskPale",
"goldenBrown",
"braise",
"truss",
"caramelize",
"score",
"lineMold",
"clarify",
"compote",
"concasse",
"confit",
"julienne",
"brunoise",
"mirepoix",
"paysanne",
"blindBake",
"bainMarie",
"smother",
"decant",
"dilute",
"punchDown",
"disgorge",
"loosen",
"shellEgg",
"scald",
"pod",
"emulsify",
"hollowOut",
"shock",
"setGel",
"glaze",
"thicken",
"filet",
"proof",
"peelBlanch",
"whipUp",
"moisten",
"pasteurize",
"poach",
"reduce",
"rubIn",
"dustWithFlour",
"sweat",
"sift",
"toast",
"zest",
];
// Same authoring convention as `TECH_STEPS` right above (stable English
// camelCase uid, French label in `apps/web`'s `locales/fr/translation.json`
// under `catalog.utensils.<key>`) — but unlike `TECH_STEPS`, the matching
// data (per-locale synonym lists a `PhraseMatcher` matches against) lives
// in `services/tech-step-intent-service/intent_service/utensil_vocabulary.py`'s
// `UTENSIL_VOCABULARY`, not `training_data.py`: no textcat/training
// involved, a utensil mention doesn't need to be classified, only matched.
// Every entry here must have a matching entry there. See
// `StepTechStepUtensil` in schema.prisma for how a mention gets attached to
// a detected technique.
export const UTENSILS: string[] = [
"pan",
"saucepan",
"pot",
"knife",
"whisk",
"bowl",
"bakingSheet",
"mold",
"colander",
"cuttingBoard",
"oven",
"blender",
"mixer",
"spatula",
"ladle",
"grater",
"rollingPin",
"lid",
"tongs",
"peeler",
"sieve",
"foodProcessor",
"steamerBasket",
"skewer",
"pastryBrush",
"ramekin",
"dish",
"wok",
"thermometer",
"mandoline",
];
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
@ -1294,12 +1199,6 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
await prisma.techStep.upsert({ where: { key }, update: {}, create: { key } });
}
// Utensil: same idempotent bare id/key upsert as TechStep right above —
// no matching data alongside it either (see `UTENSILS`' own comment).
for (const key of UTENSILS) {
await prisma.utensil.upsert({ where: { key }, update: {}, create: { key } });
}
// `Allergy` itself carries no `key` — it's the selectable instance of a
// keyed `Category` (see schema.prisma) — so seeding an allergen means one
// Category (upserted by key) plus exactly one Allergy row under it,

View file

@ -111,171 +111,6 @@ function containsSubsequence(haystack: string[], needle: string[]): boolean {
return false;
}
/** One stemmed word from {@link tokenizeWithOffsets}, alongside its `[start, end)` span in the *original* (un-normalized) text it came from. */
interface OffsetToken {
word: string;
start: number;
end: number;
}
/** Matches a run of letters (any script, diacritics included) — the same "word" unit {@link tokenize} splits `normalizeText`'d text on (`/[^a-z]+/`), applied here directly to the *original* text instead so each token keeps its real character offsets. Digits/punctuation are never part of a run, same separator role they play for `tokenize` (a leading quantity is `extractQuantity`'s job, not this module's word-tokenizer's). */
const LETTER_RUN_PATTERN = /\p{L}+/gu;
/**
* {@link tokenize}'s positional twin: same stemmed/normalized words, but
* each one keeps the `[start, end)` span it occupies in `text` needed by
* {@link findIngredientMentions} to report *where* a mention is, not just
* that the catalog has a matching label somewhere. Splitting the original
* text into letter-runs first (rather than normalizing the whole string up
* front, the way `tokenize` does, then losing track of offsets) works
* safely here because `normalizeText` only ever rewrites a character's own
* form (case/diacritics) see `_DiacriticsNormalizer`'s doc comment on the
* Python side, ported from the same guarantee never merges or splits
* words, so normalizing one already-isolated run in place can't shift its
* boundaries relative to the un-normalized text.
*/
function tokenizeWithOffsets(text: string, locale = "en"): OffsetToken[] {
const tokens: OffsetToken[] = [];
for (const match of text.matchAll(LETTER_RUN_PATTERN)) {
const raw = match[0];
const start = match.index ?? 0;
const word = stemWord(normalizeText(raw), locale);
if (word.length === 0) continue;
tokens.push({ word, start, end: start + raw.length });
}
return tokens;
}
/**
* Matches a quantity (integer/decimal/fraction/mixed number, same shapes as
* {@link extractQuantity}) immediately followed by an optional unit
* word/phrase (up to three words, e.g. "cuillères à soupe") and an optional
* connector ("de"/"d'"/"of"/"a"/"an"), anchored at the *end* of whatever
* string it's tested against (`$`) rather than the start. Anchoring at the
* end not the start is what lets {@link findQuantityBeforeIngredient}
* test the *whole* text preceding a mention without first having to guess
* where an unrelated preamble ("ajouter", "puis", an earlier sentence) ends
* and the quantity phrase begins: whatever doesn't fit the pattern
* immediately before the ingredient simply isn't part of the match, no
* separate boundary-finding step needed.
*/
const QUANTITY_BEFORE_INGREDIENT_PATTERN =
/(\d+\s+\d+\/\d+|\d+\/\d+|\d+(?:[.,]\d+)?)\s*((?:\p{L}+\s+){0,2}\p{L}*)\s*(?:de\s|d[']|of\s|a\s|an\s)?$/u;
/**
* Best-effort quantity+unit lookup for an ingredient mention {@link findIngredientMentions}
* just found at `mentionStart` in `text` looks *only* at what immediately
* precedes the mention (see {@link QUANTITY_BEFORE_INGREDIENT_PATTERN}), the
* dominant French/English recipe phrasing ("200g de beurre", "2 cuillères à
* soupe d'huile", "3 œufs"). Both `null` when nothing recognizable precedes
* it (no leading digit at all) same "no match, not an error" posture as
* {@link extractQuantity}. Doesn't detect a quantity that *follows* its
* ingredient ("du beurre, 50g") an accepted gap, same trade-off
* {@link extractQuantity} already documents for the leading-only case it
* was built for.
*/
function findQuantityBeforeIngredient(
text: string,
mentionStart: number,
unitCatalog: UnitMatchEntry[],
locale: string,
): { quantity: number | null; unitId: number | null } {
const match = QUANTITY_BEFORE_INGREDIENT_PATTERN.exec(text.slice(0, mentionStart));
if (!match) return { quantity: null, unitId: null };
const { quantity } = extractQuantity(match[1] ?? "");
const unitId = matchUnit(match[2] ?? "", unitCatalog, locale);
return { quantity, unitId };
}
/** One ingredient mention {@link findIngredientMentions} found in a free-text clause, alongside its `[start, end)` span (same convention as `TechStepMatch`, `tech-step-matcher.ts`) and any quantity+unit resolved immediately before it (see {@link findQuantityBeforeIngredient}) — both `null` when the clause names the ingredient with no quantity ("ajouter le sel"). */
export interface IngredientMention {
ingredientId: number;
start: number;
end: number;
quantity: number | null;
unitId: number | null;
}
/**
* Scans `text` (typically one technique's clause, see `tech-step-matcher.ts`'s
* `splitIntoClauses`) for every mention of a catalog ingredient, left to
* right, non-overlapping the free-text-*scanning* counterpart to
* {@link matchIngredientName} (which resolves one *already-isolated*
* ingredient-line string to a single winner, not several mentions spread
* across a longer text). Same "longest catalog label wins" rule as
* {@link matchIngredientName}, applied at every token position in turn: once
* a mention is found, scanning resumes right after it rather than
* considering a shorter label starting inside an already-matched longer one.
*
* `locale` must match whatever `ingredientCatalog`/`unitCatalog` were loaded
* in (see {@link loadIngredientCatalog}/{@link loadUnitCatalog}) defaults
* to `"en"`, same as every other function in this module.
*/
export function findIngredientMentions(
text: string,
ingredientCatalog: IngredientMatchEntry[],
unitCatalog: UnitMatchEntry[],
locale = "en",
): IngredientMention[] {
const tokens = tokenizeWithOffsets(text, locale);
if (tokens.length === 0) return [];
const candidates = ingredientCatalog
.map((entry) => ({
ingredientId: entry.ingredientId,
labelTokens: tokenize(entry.label, locale),
}))
.filter((entry) => entry.labelTokens.length > 0);
const mentions: IngredientMention[] = [];
let i = 0;
while (i < tokens.length) {
let best: { ingredientId: number; tokenCount: number } | null = null;
for (const candidate of candidates) {
const { labelTokens } = candidate;
if (i + labelTokens.length > tokens.length) continue;
const matches = labelTokens.every((word, offset) => tokens[i + offset]?.word === word);
if (!matches) continue;
if (
best === null ||
labelTokens.length > best.tokenCount ||
(labelTokens.length === best.tokenCount && candidate.ingredientId < best.ingredientId)
) {
best = { ingredientId: candidate.ingredientId, tokenCount: labelTokens.length };
}
}
if (best === null) {
i += 1;
continue;
}
const startToken = tokens[i];
const endToken = tokens[i + best.tokenCount - 1];
if (startToken === undefined || endToken === undefined) {
// Unreachable — `best` was only ever set above after confirming
// `i + labelTokens.length <= tokens.length`, so both tokens exist.
// Satisfies `noUncheckedIndexedAccess`.
i += 1;
continue;
}
const { quantity, unitId } = findQuantityBeforeIngredient(
text,
startToken.start,
unitCatalog,
locale,
);
mentions.push({
ingredientId: best.ingredientId,
start: startToken.start,
end: endToken.end,
quantity,
unitId,
});
i += best.tokenCount;
}
return mentions;
}
/**
* Resolves free-text `name` (a `ParsedRecipeIngredient.name`, e.g. `"large
* diced yellow onions"`) to the best-matching `Ingredient` in `catalog`, or

View file

@ -1,102 +0,0 @@
import { env } from "../../config/env.js";
/**
* Thin fetch wrapper around `services/tech-step-intent-service`'s HTTP
* contract (`POST /v1/process`) the microservice
* {@link TechStepClassifierService} (`tech-step-matcher.ts`) delegates NER +
* intent classification to, in place of the `node-nlp` `NlpManager` it used
* to own directly. See that service's own README for the full contract and
* why it never touches Postgres itself it also owns its own training
* corpus now (`training_data.py`), trained once at its own startup, so
* `apps/api` never pushes anything to it; `process()` below is this
* client's only method.
*
* Authenticated with `INTENT_SERVICE_SECRET` the inverse direction of
* `requireInternalWorker`'s `INTERNAL_WORKER_SECRET` (this time `apps/api`
* is the caller, not the callee), but the same "one flat shared secret"
* shape.
*/
/** One candidate mention one of the service's two `PhraseMatcher`s found — offsets `[start, end)`, same convention as `String.prototype.slice`. Mirrors `EntityPayload` (Python `schemas.py`). `kind` distinguishes a technique mention (`self._matcher`, the corpus-trained one) from a utensil mention (`self._utensil_matcher`, static — see `utensil_vocabulary.py`) — `tech-step-matcher.ts` resolves each against a different catalog (`TechStep`/`Utensil`). */
export interface IntentServiceEntity {
uid: string;
start: number;
end: number;
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). */
export interface IntentServiceProcessResult {
entities: IntentServiceEntity[];
intent: string | null;
score: number;
}
/**
* Client for `services/tech-step-intent-service` a real class (not a
* plain object of functions) per this repo's service-style-logic
* convention, even though it holds no state of its own: it's used as the
* one shared {@link intentServiceClient} singleton below, same reasoning as
* `TechStepClassifierService` itself.
*/
export class IntentServiceClient {
/**
* Performs a JSON request against the intent service and returns the
* parsed body.
*
* @throws {Error} if the response status is not in the 2xx range, or the
* request itself fails (network error, service down) left as a plain
* `Error` rather than a typed `HttpError`: this is an internal
* service-to-service call, not a request `apps/api`'s own HTTP layer
* needs to map to a client-facing status code (see
* `TechStepClassifierService.warmUp`'s retry in `server.ts` for how a
* failure here is actually handled).
*/
private async _request<TResponseBody>(
path: string,
init: RequestInit = {},
): Promise<TResponseBody> {
try {
const response = await fetch(`${env.INTENT_SERVICE_BASE_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
"X-Intent-Service-Secret": env.INTENT_SERVICE_SECRET,
...init.headers,
},
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`${init.method ?? "GET"} ${path} failed: ${response.status} ${body}`);
}
return (await response.json()) as TResponseBody;
} catch (err) {
// Rethrown as-is — every caller (`TechStepClassifierService`) already
// wraps its own `await`s per the repo's try/catch convention; this is
// just where the `await` itself has to sit inside one.
throw err;
}
}
/**
* Equivalent to the old `NlpManager.process(locale, text)` returns every
* candidate technique mention (NER) plus the intent classifier's verdict
* for `text` as a whole, whether `text` is a full step description or a
* single clause `TechStepClassifierService` already cut out of one (this
* service doesn't know or care which, exactly like `NlpManager` before
* it).
*/
public async process(locale: string, text: string): Promise<IntentServiceProcessResult> {
try {
return await this._request("/v1/process", {
method: "POST",
body: JSON.stringify({ locale, text }),
});
} catch (err) {
throw err;
}
}
}
/** Single shared instance — stateless, no reason for more than one (same reasoning as `techStepClassifier`/`prisma`). */
export const intentServiceClient = new IntentServiceClient();

View file

@ -2,8 +2,8 @@
* Hand-labeled evaluation set for {@link techStepClassifier} what
* `tech-step-eval.test.ts` runs the real classifier against to compute
* precision/recall/F1 (`tech-step-evaluator.ts`), the objective gate any
* future change to `services/tech-step-intent-service`'s `training_data.py`
* must clear (see that module's own doc comment).
* future change to `tech-step-training-data.ts` must clear (see that
* module's own doc comment).
*
* Deliberately *not* reusing `TECH_STEP_TRAINING_DATA`'s own `utterances`
* verbatim scoring the classifier against the exact sentences it was
@ -246,7 +246,7 @@ export const TECH_STEP_EVAL_DATASET: TechStepEvalCase[] = [
// --- Documented false-positive traps, re-verified with fresh wording ---
// `brown`'s EN synonyms are verb forms only ("browned"/"browning"), not
// bare "brown" — precisely so this doesn't false-positive (see that
// entry's own comment in training_data.py).
// entry's own comment in tech-step-training-data.ts).
{
description: "This recipe calls for two tablespoons of brown sugar.",
locale: "en",

View file

@ -3,9 +3,8 @@
* hand-labeled evaluation set (`tech-step-eval-dataset.ts`) the objective
* counterpart to the "inspected by eye" verdict every corpus change used to
* get before this module existed. Every future edit to
* `services/tech-step-intent-service`'s `training_data.py` (including the
* LLM-assisted suggestions the worker in `services/tech-step-llm-worker`
* proposes) is expected to run
* `tech-step-training-data.ts` (including the LLM-assisted suggestions the
* worker in `services/tech-step-llm-worker` proposes) is expected to run
* through `tech-step-eval.test.ts`'s regression gate, which calls
* {@link computeTechStepMetrics} a corpus change that raises recall on one
* technique but silently tanks another's precision should fail loudly here,

View file

@ -1,11 +1,6 @@
import { NlpManager } from "node-nlp";
import { prisma } from "../../db/prisma.js";
import {
findIngredientMentions,
type IngredientMention,
loadIngredientCatalog,
loadUnitCatalog,
} from "./ingredient-matcher.js";
import { intentServiceClient } from "./intent-service-client.js";
import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js";
/**
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe
@ -20,27 +15,25 @@ import { intentServiceClient } from "./intent-service-client.js";
* generalize past its own vocabulary a step describing melting butter as
* "jusqu'à ce que le beurre ait disparu dans la poêle" mentions no verb any
* regex could anchor on, yet unmistakably *means* `melt`. Replaced with a
* small hybrid pipeline (originally built on `node-nlp`, now entirely
* delegated to `services/tech-step-intent-service` a spaCy-based
* microservice, see {@link IntentServiceClient} and that service's own
* README):
* small hybrid pipeline built on `node-nlp` ({@link TechStepClassifierService}):
*
* 1. **NER** (the intent service's `PhraseMatcher`, built from its own
* `training_data.py`'s `synonyms`) finds every *candidate* technique
* mention in the whole description, each with its exact character span
* mechanically the same job the old regexes did, just as flat synonym
* lists instead of hand-written patterns. This step alone is *not* the
* final answer see step 3.
* 1. **NER** (node-nlp enum entities, `synonyms` in `TECH_STEP_TRAINING_DATA`)
* finds every *candidate* technique mention in the whole
* description, each with its exact character span mechanically the
* same job the old regexes did, just as flat synonym lists instead of
* hand-written patterns (node-nlp's own stemmer/fuzzy matching already
* covers minor conjugation/typo variance the regexes had to enumerate
* by hand). This step alone is *not* the final answer see step 3.
* 2. The description is cut into clauses around those candidate spans
* ({@link splitIntoClauses}) a step naming two techniques ("Dans une
* poêle chaude, faire chauffer une noix de beurre" is both `preheat`
* and `melt`) needs each judged on its own surrounding context, not the
* whole step lumped into one classification.
* 3. **NLP intent classification** (the intent service's `textcat`, trained
* on its own `training_data.py`'s `utterances`) then classifies each
* clause on its own this is what actually delivers "meaning, not
* keywords": the classifier was deliberately trained on paraphrases that
* never use the technique's own verb (e.g. "jusqu'à ce que le beurre ait
* 3. **NLP intent classification** (node-nlp's `NlpManager`, trained on
* `TECH_STEP_TRAINING_DATA`'s `utterances`) then classifies each clause
* on its own this is what actually delivers "meaning, not keywords":
* the classifier was deliberately trained on paraphrases that never use
* the technique's own verb (e.g. "jusqu'à ce que le beurre ait
* disparu" for `melt`), so a clause reaching it gets labeled by what it
* was trained to recognize as *meaning* a technique, not by which
* literal word the NER step happened to anchor on. The NER-implied
@ -56,12 +49,12 @@ import { intentServiceClient } from "./intent-service-client.js";
*
* `normalizeText` and {@link splitIntoClauses} are pure (no DB/model
* access) so they stay unit-testable in isolation (see
* `test/tech-step-matcher.test.ts`); this class only ever needs a
* `TechStep.key -> id` lookup from the DB, memoized on the shared
* {@link techStepClassifier} singleton rather than repeated per call the
* NLP model itself trains once, inside `services/tech-step-intent-service`'s
* own startup, entirely independently of this class (see that service's
* README this repo no longer pushes any corpus to it over HTTP).
* `test/tech-step-matcher.test.ts`); the classifier itself needs a one-time
* training pass (`_ensureTrained`, node-nlp's `NlpManager.train()`) plus a
* `TechStep.key -> id` lookup from the DB, both memoized on the shared
* {@link techStepClassifier} singleton rather than repeated per call
* training is the expensive part (a few hundred ms for this corpus), never
* worth redoing per request let alone per step.
*/
/**
@ -96,15 +89,6 @@ export function normalizeText(text: string): string {
* Persisted as `StepTechStep.start`/`end`/`contextStart`/`contextEnd`
* (`recipe.service.ts`) so the recipe detail view can highlight both spans,
* not just know a technique was mentioned somewhere.
*
* `ingredients`/`utensils` are the metadata found in this match's own
* *clause* (see this file's doc comment, point 2) an ingredient/utensil
* mentioned in a different clause of the same description belongs to
* *that* clause's own match, never this one, the same "judged on its own
* surrounding context" rule the technique itself is judged by. Always `[]`
* rather than omitted when nothing was found, so every caller can iterate
* unconditionally. Persisted as `StepTechStepIngredient`/`StepTechStepUtensil`
* rows (`recipe.service.ts`).
*/
export interface TechStepMatch {
techStepId: number;
@ -112,21 +96,6 @@ export interface TechStepMatch {
end: number;
contextStart: number;
contextEnd: number;
ingredients: IngredientMention[];
utensils: UtensilMention[];
}
/**
* A utensil mention found by the intent service's utensil `PhraseMatcher`
* (`kind: "utensil"` entities in `IntentServiceProcessResult`, see
* `intent-service-client.ts`), resolved to a local `Utensil.id` and
* attributed to whichever clause its span falls inside same
* `[start, end)` convention as every other span in this file.
*/
export interface UtensilMention {
utensilId: number;
start: number;
end: number;
}
/** A candidate technique mention found by NER — the raw material {@link splitIntoClauses} cuts a description around. */
@ -272,31 +241,18 @@ export function splitIntoClauses(
* `TECH_STEP_TRAINING_DATA` see `test/tech-step-matcher.test.ts` for the
* cases this threshold was picked to pass.
*
* Recalibrated for the migration off `node-nlp` to
* `services/tech-step-intent-service` (spaCy `textcat`, exclusive classes)
* its score distribution is meaningfully different from node-nlp's own
* classifier, and shifts again every time the corpus' technique count
* changes (more exclusive classes generally means a *lower* natural
* confidence ceiling, softmax mass spread thinner).
*
* Currently `0.25`, set against the corpus as expanded to ~74 techniques
* (`services/tech-step-intent-service/intent_service/training_data.py`,
* `_TRAINING_ITERATIONS = 25`, `textcat` trained on each technique's own
* `synonyms` in addition to its `utterances` see that constant's own
* comment for the calibration history) from manual spot-checks, not yet a
* real `calibrate-tech-step-threshold.ts` sweep against
* `TECH_STEP_EVAL_DATASET` (needs Postgres see that script's own doc
* comment): observed real-case scores ranged `0.31`-`0.89` (`simmer`
* lowest, still correct in argmax and anchored anyway; `melt` highest, the
* motivating anchor-less case), against a noise floor around `0.02`
* (English text through the French classifier). `0.25` sits with real
* margin above the noise floor and below every real case seen so far, but
* **this is a placeholder pending the real eval-dataset sweep** do not
* treat it as load-bearing precision the way the original `0.45`
* (calibrated against the ~26-technique corpus, `TECH_STEP_EVAL_DATASET`
* F1 plateauing exactly there) was.
* Raised from `0.65` after finding real (non-adversarial) misclassified
* clauses that scored just above the old threshold e.g. English recipe
* text run through the French classifier (which must find *nothing*,
* confirmed by `recipe-translation.test.ts`'s own locale-isolation test)
* scored `0.69` for `boil`, essentially classifier noise on
* out-of-vocabulary input rather than a real, confident verdict. The
* clauses this threshold exists to actually trust score far higher in
* practice (`0.91``1.0` for the real corrected cases found this session)
* `0.75` sits comfortably above the noise floor and below every genuine
* match seen so far.
*/
export const CONFIDENCE_THRESHOLD = 0.25;
export const CONFIDENCE_THRESHOLD = 0.75;
/**
* One clause's full classification detail the finer-grained sibling of
@ -317,45 +273,70 @@ export interface TechStepClauseClassification {
end: number;
/** The clause's NER anchor's own implied technique `uid`, if it had one — same as `TechStepClause.anchor.uid`. */
anchorUid: string | null;
/** The intent classifier's own top guess for this clause, whatever its score — `null` only when the intent service had nothing trained for `locale`, or the clause text was blank. Unlike {@link TechStepMatch}, never silently replaced by the anchor's uid — the whole point of this type is to expose the classifier's raw opinion, confident or not. */
/** The intent classifier's own top guess for this clause, whatever its score — `null` only when it returned node-nlp's `"None"` sentinel. Unlike {@link TechStepMatch}, never silently replaced by the anchor's uid — the whole point of this type is to expose the classifier's raw opinion, confident or not. */
intentUid: string | null;
/** The intent classifier's own confidence for `intentUid` — `0` when `intentUid` is `null` (nothing to have a score about). */
score: number;
}
/**
* Owns the `TechStep.key -> id` lookup behind {@link matchTechStepSpans}
* Trains and owns the `node-nlp` model behind {@link matchTechStepSpans}
* a real class (not a plain object of functions) per this repo's
* service-style-logic convention, even though it's only ever used as the
* one shared {@link techStepClassifier} singleton below: it holds real
* state (the memoized lookup promise), not just grouped stateless helpers.
* The actual NER/intent-classification model lives entirely in
* `services/tech-step-intent-service` (a separate process, trained from
* its own `training_data.py` at its own startup) this class never
* trains or pushes anything to it, it only calls `POST /v1/process` and
* resolves whatever `uid` comes back to a local DB id.
* state (the trained model, the memoized training/lookup promises), not
* just grouped stateless helpers.
*/
export class TechStepClassifierService {
/** Memoized `TechStep.key -> id` lookup — resolved from the DB once, reused by every call rather than queried per request. `undefined` until the first call starts loading it, after which every caller (concurrent or not) awaits the same promise. */
private _techStepIdsLoaded: Promise<void> | undefined;
/** node-nlp's manager — both NER (enum entities) and NLP (intent classification) live on the same instance, trained together. */
private readonly _manager: NlpManager;
/** Memoized training pass — `undefined` until the first call starts it, after which every caller (concurrent or not) awaits the same promise rather than retraining. */
private _trained: Promise<void> | undefined;
/** Memoized `TechStep.key -> id` lookup — training data only knows techniques by their stable `uid`/`key`, resolved to the real DB id once, alongside training. */
private _techStepIdByUid: Map<string, number> | undefined;
/** Same memoized-lookup shape as {@link _techStepIdsLoaded}/{@link _techStepIdByUid}, for `Utensil.key -> id` instead — a `kind: "utensil"` entity from the intent service resolves through this map, never `_techStepIdByUid`. */
private _utensilIdsLoaded: Promise<void> | undefined;
private _utensilIdByUid: Map<string, number> | undefined;
public constructor() {
this._manager = new NlpManager({
languages: ["fr", "en"],
forceNER: true,
nlu: { log: false },
// node-nlp's enum-entity NER defaults to a fuzzy (Levenshtein-based)
// 0.8 accuracy threshold — loose enough that e.g. "faire" (the
// generic French helper verb in almost every recipe step) fuzzy-
// matches `fry`'s synonym "frire" at 0.80, a false positive found
// while tuning this against the real training corpus. `1` (exact,
// after node-nlp's own case/accent/stemming normalization — real
// conjugation variance is still covered by listing each form in
// `tech-step-training-data.ts`) removed it without losing any real
// match. Precision matters more than recall for this stage — NER
// only proposes candidate split points, `_classifyClause`'s trained
// model (not fuzzy string distance) is what actually has to be
// right.
ner: { threshold: 1 },
// node-nlp defaults to `autoSave`/`autoLoad: true` — silently
// persisting the trained model to a `model.nlp` file in the process's
// cwd, and *loading from that file instead of retraining* the next
// time a manager is constructed, if the file already exists. Found
// this the hard way: a stray `model.nlp` appeared at the repo root
// after running this locally. That's the opposite of what this
// service wants — `TECH_STEP_TRAINING_DATA` in code is the single
// source of truth this always trains fresh from (see this file's own
// doc comment) — a stale on-disk model silently shadowing a
// corpus/threshold update would be a nasty, hard-to-notice class of
// bug. Both off; nothing here should ever touch disk.
autoSave: false,
autoLoad: false,
});
}
/**
* Forces the `TechStep.key -> id` lookup to load now, synchronously with
* server startup (see `server.ts`, which also retries this against a
* not-yet-reachable intent service), rather than stalling whichever
* request happens to be first to save/preview a recipe. Doesn't wait on
* `services/tech-step-intent-service` finishing its own training that
* service is only ever considered "up" by Docker Compose/CI once it
* already is (see that service's `GET /health`), so by the time this
* runs in a real deployment it's already trained; a request racing an
* intent service that's genuinely still starting just gets an empty
* match list back (see `IntentServiceProcessResult`'s own doc comment),
* not an error.
* Forces training plus node-nlp's own one-time lazy setup (loading its
* bundled per-language stemmers/tokenizers on the *first* real
* `NlpManager.process()` call takes a few seconds by itself, separate
* from and much slower than the ~40ms `train()` pass measured against
* this corpus while tuning the pipeline) to happen now, synchronously
* with server startup (see `server.ts`), rather than stalling whichever
* request happens to be first to save/preview a recipe.
*/
public async warmUp(): Promise<void> {
try {
@ -379,35 +360,26 @@ export class TechStepClassifierService {
*/
public async matchTechStepSpans(description: string, locale: string): Promise<TechStepMatch[]> {
try {
await Promise.all([this._ensureTechStepIdsLoaded(), this._ensureUtensilIdsLoaded()]);
await this._ensureTrained();
if (description.trim().length === 0) return [];
// Loaded fresh per call (once per step, see `recipe.service.ts`'s
// `matchStepsTechSteps`) rather than memoized like the id lookups
// above — same "cheap enough, and reference data can change between
// calls without a restart" posture `loadIngredientCatalog`/
// `loadUnitCatalog`'s own doc comments already describe for their
// other callers (`ingredient-matcher.ts`, `sources.service.ts`).
const [ingredientCatalog, unitCatalog] = await Promise.all([
loadIngredientCatalog(locale),
loadUnitCatalog(locale),
]);
// The intent service returns two kinds of candidate (see `kind` on
// `IntentServiceEntity`): technique mentions (its corpus-trained
// `PhraseMatcher`) and utensil mentions (its static one, see
// `utensil_vocabulary.py`). Only the former ever anchor a clause —
// `splitIntoClauses` cuts a description around *techniques*, a
// mentioned utensil doesn't introduce a clause boundary of its own,
// it just gets attributed to whichever clause its span falls inside
// (see the loop below). Its `start`/`end` are already `[start, end)`
// (matching `String.prototype.slice`), unlike node-nlp's inclusive
// `end` — no `+ 1` needed either.
const nerResult = await intentServiceClient.process(locale, description);
const nerResult = await this._manager.process(locale, description);
const candidates: TechniqueCandidate[] = nerResult.entities
.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");
// node-nlp's language plugins also auto-extract their own built-in
// entities (numbers, durations, dates…) alongside the enum
// entities `_train` registered from `TECH_STEP_TRAINING_DATA` —
// `type === "enum"` is what tells the two apart; without this
// filter a step like "10 minutes" would hand `splitIntoClauses` a
// bogus "duration" candidate that resolves to no real technique.
.filter((entity) => entity.type === "enum")
.map((entity) => ({
uid: entity.entity,
start: entity.start,
// node-nlp's own `end` is inclusive (verified against a real
// trained model) — `+ 1` converts to this module's `[start, end)`
// convention, matching `String.prototype.slice`.
end: entity.end + 1,
}));
const clauses = splitIntoClauses(description, candidates);
const matches: TechStepMatch[] = [];
@ -421,35 +393,12 @@ export class TechStepClassifierService {
// persist a dangling id.
if (techStepId === undefined) continue;
const span = clause.anchor ?? { start: clause.start, end: clause.end };
const ingredients = findIngredientMentions(
description.slice(clause.start, clause.end),
ingredientCatalog,
unitCatalog,
locale,
).map((mention) => ({
...mention,
start: mention.start + clause.start,
end: mention.end + clause.start,
}));
const utensils: UtensilMention[] = utensilEntities.flatMap((entity) => {
if (entity.start < clause.start || entity.end > clause.end) return [];
const utensilId = this._utensilIdByUid?.get(entity.uid);
// Same drift guard as `techStepId` above.
return utensilId === undefined
? []
: [{ utensilId, start: entity.start, end: entity.end }];
});
matches.push({
techStepId,
start: span.start,
end: span.end,
contextStart: clause.start,
contextEnd: clause.end,
ingredients,
utensils,
});
}
@ -479,13 +428,17 @@ export class TechStepClassifierService {
locale: string,
): Promise<TechStepClauseClassification[]> {
try {
await this._ensureTechStepIdsLoaded();
await this._ensureTrained();
if (description.trim().length === 0) return [];
const nerResult = await intentServiceClient.process(locale, description);
const nerResult = await this._manager.process(locale, description);
const candidates: TechniqueCandidate[] = nerResult.entities
.filter((entity) => entity.kind === "technique")
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
.filter((entity) => entity.type === "enum")
.map((entity) => ({
uid: entity.entity,
start: entity.start,
end: entity.end + 1,
}));
const clauses = splitIntoClauses(description, candidates);
const results: TechStepClauseClassification[] = [];
@ -503,14 +456,15 @@ export class TechStepClassifierService {
});
continue;
}
const result = await intentServiceClient.process(locale, clauseText);
const result = await this._manager.process(locale, clauseText);
const intentUid = result.intent !== "None" ? result.intent : null;
results.push({
clauseText,
start: clause.start,
end: clause.end,
anchorUid,
intentUid: result.intent,
score: result.intent === null ? 0 : result.score,
intentUid,
score: intentUid === null ? 0 : result.score,
});
}
return results;
@ -552,8 +506,8 @@ export class TechStepClassifierService {
const clauseText = description.slice(clause.start, clause.end).trim();
if (clauseText.length === 0) return clause.anchor?.uid ?? null;
const result = await intentServiceClient.process(locale, clauseText);
if (result.intent !== null && result.score >= CONFIDENCE_THRESHOLD) {
const result = await this._manager.process(locale, clauseText);
if (result.intent !== "None" && result.score >= CONFIDENCE_THRESHOLD) {
return result.intent;
}
return clause.anchor?.uid ?? null;
@ -563,56 +517,52 @@ export class TechStepClassifierService {
}
/**
* Resolves the `uid -> TechStep.id` lookup exactly once memoized on
* `_techStepIdsLoaded` so a burst of concurrent calls (several steps of
* the same recipe save, awaited via the same event loop tick) all await
* the one in-flight DB query rather than each firing their own.
* Trains `_manager` from {@link TECH_STEP_TRAINING_DATA} and resolves the
* `uid -> TechStep.id` lookup, both exactly once memoized on
* `_trained` so a burst of concurrent calls (several steps of the same
* recipe save, awaited via the same event loop tick) all await the one
* in-flight training pass rather than each kicking off their own.
*/
private async _ensureTechStepIdsLoaded(): Promise<void> {
if (this._techStepIdsLoaded === undefined) {
this._techStepIdsLoaded = this._loadTechStepIds();
private async _ensureTrained(): Promise<void> {
if (this._trained === undefined) {
this._trained = this._train();
}
try {
await this._techStepIdsLoaded;
await this._trained;
} catch (err) {
// A failed load must be retried by the *next* call, not leave every
// future call permanently rejecting against a stale failed promise.
this._techStepIdsLoaded = undefined;
// A failed training pass must be retried by the *next* call, not
// leave every future call permanently rejecting against a stale
// failed promise.
this._trained = undefined;
throw err;
}
}
private async _loadTechStepIds(): Promise<void> {
private async _train(): Promise<void> {
try {
const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
this._techStepIdByUid = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
} catch (err) {
throw err; // see matchTechStepSpans()'s catch comment above
for (const entry of TECH_STEP_TRAINING_DATA) {
for (const [locale, data] of [
["fr", entry.fr],
["en", entry.en],
] as const) {
if (data.synonyms.length > 0) {
this._manager.addNamedEntityText(entry.uid, entry.uid, [locale], data.synonyms);
}
for (const utterance of data.utterances) {
this._manager.addDocument(locale, utterance, entry.uid);
}
}
}
/** `Utensil.key -> id` counterpart of {@link _ensureTechStepIdsLoaded} — same memoize-once-retry-on-failure shape. */
private async _ensureUtensilIdsLoaded(): Promise<void> {
if (this._utensilIdsLoaded === undefined) {
this._utensilIdsLoaded = this._loadUtensilIds();
}
try {
await this._utensilIdsLoaded;
} catch (err) {
this._utensilIdsLoaded = undefined;
throw err;
}
}
private async _loadUtensilIds(): Promise<void> {
try {
const utensils = await prisma.utensil.findMany({ select: { id: true, key: true } });
this._utensilIdByUid = new Map(utensils.map((utensil) => [utensil.key, utensil.id]));
await this._manager.train();
} catch (err) {
throw err; // see matchTechStepSpans()'s catch comment above
}
}
}
/** Single shared instance — every caller reuses the one memoized `TechStep.key -> id` lookup rather than re-querying the DB. The actual model training (expensive — a couple of minutes, both locales combined) happens entirely inside `services/tech-step-intent-service`'s own startup, not here — see that service's `_TRAINING_ITERATIONS`. */
/** Single shared instance — training is expensive enough (a few hundred ms) that every caller must reuse the one already-trained model, never spin up their own. */
export const techStepClassifier = new TechStepClassifierService();

View file

@ -0,0 +1,911 @@
/**
* Training corpus for {@link TechStepClassifierService} (`tech-step-matcher.ts`)
* one entry per `TechStep` (`uid` matches `reference-seed-data.ts`'s
* `TECH_STEPS`, which still owns the reference `TechStep` rows themselves;
* this file replaces `TECH_STEPS[].mappings`' regex expressions as the
* *matching* data source).
*
* Two distinct kinds of content per technique/locale, feeding two distinct
* mechanisms of the classifier (see that file's doc comment for why both
* are needed):
*
* - `synonyms` short literal words/set phrases, fed to node-nlp's NER
* (enum entities). Mechanically equivalent to the old regexes' verb-form
* alternations, just spelled out as plain words instead of a pattern
* (node-nlp's own stemmer/fuzzy matching already covers minor
* conjugation/typo variance that the regexes had to enumerate by hand).
* Used only to find *candidate* technique mentions and cut a step into
* clauses around them never the final answer on their own.
* - `utterances` full example clauses, fed to node-nlp's NLP Manager as
* training documents for the intent classifier. Deliberately mixes
* keyword-anchored phrasings (reinforces the obvious case) with
* paraphrases that never use the technique's own verb at all (e.g.
* "jusqu'à ce que le beurre ait disparu" for `melt`) this second kind
* is what actually delivers on "comprendre le sens, pas juste les mots
* clés" (see the PR this file was introduced in): a clause reaching the
* classifier gets labeled by what it's trained to recognize as *meaning*
* this technique, not by which literal word triggered its extraction.
*
* Kept as static in-code data (not DB rows, unlike the old
* `TechStepMapping` table) because nothing needs to query/edit it at
* runtime it only ever feeds one thing, the classifier's one-time
* training pass (see `TechStepClassifierService._ensureTrained`) same
* reasoning `INGREDIENT_LABELS_EN` (`packages/shared`) is a plain object,
* not a database table.
*/
/** One technique's matching data for one locale — see this file's doc comment for what each list feeds. */
export interface TechStepLocaleTrainingData {
synonyms: string[];
utterances: string[];
}
/** One technique's full training entry — `uid` must match a `TECH_STEPS[].uid` in `reference-seed-data.ts`. */
export interface TechStepTrainingEntry {
uid: string;
fr: TechStepLocaleTrainingData;
en: TechStepLocaleTrainingData;
}
export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
{
uid: "cook",
fr: {
synonyms: [
"cuire",
"cuisez",
"cuisant",
"cuisson",
"cuit",
"cuite",
"cuites",
"cuits",
"cuisiner",
"cuisinez",
"cuisiné",
"cuisinée",
"faire cuire",
"laisser cuire",
],
utterances: [
"faire cuire à feu moyen",
"laisser cuire jusqu'à ce que ce soit prêt",
"la cuisson dure environ dix minutes",
"jusqu'à ce que la viande ne soit plus rose au centre",
"poursuivre la cuisson à couvert",
// Two real recipe clauses found misclassified (as `preheat` and
// `panFry` respectively, both above the confidence threshold) once
// real, longer, comma-heavy sentences started reaching the
// classifier — neither error came from a missing keyword (both
// clauses' own NER anchor, "laisser cuire"/"faire cuire", was
// already right), just the classifier's low-heat/occasional-
// stirring phrasing not resembling anything short and clean-cut it
// had actually been trained on.
"baisser le feu et laisser cuire à découvert encore un quart d'heure",
"faire cuire à feu doux en remuant de temps en temps",
],
},
en: {
// NOT "cooked through"/"cooking through" — both are word-prefix
// extensions of "cooked"/"cooking" above, so any text containing them
// matches BOTH the short and long form as separate overlapping NER
// candidates, corrupting clause-splitting (confirmed via "It should
// be cooking through evenly", which spuriously grew a second,
// wrongly-classified `roast` candidate). See this pattern flagged
// throughout the file wherever it was found — the fix is always to
// drop the longer, redundant form rather than keep both.
synonyms: ["cook", "cooks", "cooked", "cooking"],
utterances: [
"cook over medium heat",
"cook until done",
"cooking takes about ten minutes",
"until no longer pink in the middle",
"continue cooking covered",
],
},
},
{
uid: "fry",
fr: {
synonyms: [
"frire",
"frit",
"frite",
"frites",
"friture",
"faire frire",
"faites frire",
"bain de friture",
"huile de friture",
],
utterances: [
"faire frire dans l'huile chaude",
"plonger dans la friture",
"jusqu'à ce que ce soit doré et croustillant à l'extérieur",
"l'huile doit être bien chaude avant d'y plonger les morceaux",
],
},
en: {
// NOT "frying oil" — a word-prefix extension of "frying" above (see
// the `cook` entry's comment for why that duplicates/corrupts NER
// candidates; here it was even worse, misclassifying as `preheat`).
synonyms: ["fry", "fries", "fried", "frying", "deep fry", "deep-fried", "deep frying"],
utterances: [
"fry in hot oil",
"deep fry until golden",
"until crisp and golden on the outside",
"the oil should be very hot before adding the pieces",
],
},
},
{
uid: "melt",
fr: {
synonyms: [
"fondre",
"fondu",
"fondue",
"fondues",
"faire fondre",
"faites fondre",
// Also a plausible way to say "melt" (heating something — usually
// a fat — until it liquefies), not just a `preheat` phrasing —
// restores what the regex-based system anchored on before this
// pipeline replaced it.
"faire chauffer",
"faites chauffer",
"liquéfier",
"liquéfiez",
"liquéfié",
"faire liquéfier",
],
utterances: [
"faire fondre le beurre",
"jusqu'à ce que le beurre ait disparu dans la poêle",
"le beurre doit être complètement liquide",
"laisser le fromage devenir tout liquide sur feu doux",
],
},
en: {
synonyms: ["melt", "melts", "melted", "melting", "liquefy", "liquefied"],
utterances: [
"melt the butter",
"until the butter has completely disappeared into the pan",
"the butter should be fully liquid",
"let the cheese turn completely liquid over low heat",
],
},
},
{
uid: "deglaze",
fr: {
// NOT "déglacer la poêle"/"déglacer le fond de cuisson" — both are
// word-prefix extensions of "déglacer" above (see `cook`'s comment
// for why that duplicates NER candidates).
synonyms: ["déglacer", "déglacez", "déglacé", "déglacée", "déglaçage"],
utterances: [
"déglacer avec le vin blanc",
"verser le vin dans la poêle chaude pour décoller les sucs",
"gratter les sucs de cuisson au fond de la casserole avec un peu de bouillon",
],
},
en: {
// NOT "deglaze the pan" — a word-prefix extension of "deglaze" above
// (see `cook`'s comment for why that duplicates NER candidates).
synonyms: ["deglaze", "deglazes", "deglazed", "deglazing", "lift the browned bits"],
utterances: [
"deglaze with white wine",
"pour the wine into the hot pan to lift the browned bits",
"scrape up the browned bits at the bottom of the pan with a splash of stock",
],
},
},
{
uid: "simmer",
fr: {
synonyms: [
"mijoter",
"mijotez",
"mijote",
"mijotant",
"mijoté",
"frémir",
"frémissant",
"frémissante",
"à petit feu",
],
utterances: [
"laisser mijoter à feu doux",
"faire mijoter pendant une heure",
"de petites bulles doivent remonter doucement à la surface",
"laisser cuire tout doucement à couvert pendant longtemps",
],
},
en: {
// NOT "simmering gently" — a word-prefix extension of "simmering"
// above (see `cook`'s comment for why that duplicates NER candidates).
synonyms: ["simmer", "simmers", "simmered", "simmering", "gentle simmer", "low simmer"],
utterances: [
"let it simmer over low heat",
"simmer for one hour",
"small bubbles should gently rise to the surface",
"let it cook very gently, covered, for a long time",
],
},
},
{
uid: "boil",
fr: {
synonyms: [
"bouillir",
"bouillant",
"bouillie",
"bouillies",
"ébullition",
"porter à ébullition",
"gros bouillons",
],
utterances: [
"porter à ébullition",
"faire bouillir l'eau",
"de grosses bulles doivent agiter la surface avec force",
"jusqu'à ce que ça bouillonne franchement",
],
},
en: {
// NOT "boiling point" — a word-prefix extension of "boiling" above
// (see `cook`'s comment for why that duplicates NER candidates).
synonyms: ["boil", "boils", "boiled", "boiling", "rolling boil"],
utterances: [
"bring to a boil",
"boil the water",
"large bubbles should be vigorously breaking the surface",
"until it's rolling vigorously",
],
},
},
{
uid: "roast",
fr: {
// NOT "rôti au four" — a word-prefix extension of "rôti" above (see
// `cook`'s comment for why that duplicates NER candidates).
synonyms: ["rôtir", "rôti", "rôtie", "rôties", "rôtis", "rôtissage"],
utterances: [
"faire rôtir la volaille entière",
"le rôti doit dorer uniformément de tous les côtés",
"cuire la pièce de viande entière au four à chaleur sèche",
],
},
en: {
synonyms: ["roast", "roasts", "roasted", "roasting", "oven-roast", "oven roasted"],
utterances: [
"roast the whole bird",
"it should brown evenly on every side",
"cook the whole piece of meat in dry oven heat",
],
},
},
{
uid: "grill",
fr: {
synonyms: [
"griller",
"grillez",
"grillé",
"grillée",
"grillées",
"grillade",
"grillades",
"barbecue",
"au barbecue",
],
utterances: [
"faire griller sur la grille du barbecue",
"marquer les steaks sur une plaque brûlante",
"des traces de quadrillage doivent apparaître à la cuisson",
],
},
en: {
synonyms: ["grill", "grills", "grilled", "grilling", "barbecue", "char-grill", "charbroiled"],
utterances: [
"grill on the barbecue rack",
"sear the steaks on a scorching-hot plate",
"char marks should appear as it cooks",
],
},
},
{
uid: "panFry",
fr: {
// Deliberately NOT "poêlé"/"poêlée"/"poêlés" here, despite reading
// like natural panFry vocabulary: node-nlp's French stemmer reduces
// them to the same root as the bare noun "poêle" (a pan), so
// registering them made every plain mention of "poêle" — e.g.
// `preheat`'s own "la poêle" — a false-positive panFry candidate too.
// Found via the "jusqu'à ce que le beurre ait disparu dans la poêle"
// regression test, which unexpectedly grew a spurious panFry match.
synonyms: ["sauter", "sautez", "sauté", "sautée", "sautées", "sautant", "à la poêle"],
utterances: [
"faire sauter les légumes à la poêle",
"saisir rapidement à feu vif en remuant sans cesse",
"faire revenir en remuant vivement dans une poêle très chaude",
],
},
en: {
synonyms: [
"sauté",
"sauteed",
"sautéed",
"sauteing",
"pan-fry",
"pan fried",
"pan-fried",
"stir-fry",
"pan searing",
"seared in a pan",
],
utterances: [
"sauté the vegetables in a pan",
"quickly sear over high heat, stirring constantly",
"cook briskly, stirring, in a very hot pan",
],
},
},
{
uid: "blanch",
fr: {
synonyms: ["blanchir", "blanchissez", "blanchi", "blanchie", "blanchies", "blanchiment"],
utterances: [
"faire blanchir les légumes deux minutes dans l'eau bouillante",
"plonger brièvement dans l'eau bouillante puis directement dans l'eau glacée",
"cuire très rapidement à l'eau bouillante avant de stopper la cuisson au froid",
],
},
en: {
// "parboil" is folded in here rather than kept a separate technique —
// in home-cooking usage (as opposed to professional usage, where they
// can differ) it names the same "briefly pre-cook in boiling water"
// move blanching does.
synonyms: [
"blanch",
"blanches",
"blanched",
"blanching",
"parboil",
"parboiled",
"parboiling",
],
utterances: [
"blanch the vegetables for two minutes in boiling water",
"briefly plunge into boiling water then straight into ice water",
"cook very quickly in boiling water before stopping it cold",
],
},
},
{
uid: "marinate",
fr: {
synonyms: [
"mariner",
"marinez",
"mariné",
"marinée",
"marinées",
"marinade",
"macérer",
"macérez",
"macération",
"faire mariner",
],
utterances: [
"laisser mariner la viande toute la nuit au réfrigérateur",
"faire tremper dans la sauce plusieurs heures avant cuisson pour parfumer",
"laisser reposer dans le mélange d'huile et d'épices avant de cuisiner",
],
},
en: {
// NOT "marinating for" — a word-prefix extension of "marinating"
// above (see `cook`'s comment for why that duplicates NER candidates
// — here it was even worse, misclassifying as `simmer`).
synonyms: [
"marinate",
"marinates",
"marinated",
"marinating",
"marinade",
"soak in the marinade",
],
utterances: [
"let the meat marinate overnight in the fridge",
"soak in the sauce for several hours before cooking to flavor it",
"let it sit in the oil and spice mixture before cooking",
],
},
},
{
uid: "chop",
fr: {
// NOT "hacher grossièrement" — a word-prefix extension of "hacher"
// above (see `cook`'s comment for why that duplicates NER candidates).
synonyms: [
"hacher",
"hachez",
"haché",
"hachée",
"hachées",
"hachis",
"couper en morceaux",
"tailler en morceaux",
],
utterances: [
"hacher finement les oignons",
"couper en tout petits morceaux irréguliers au couteau",
"réduire les herbes en petits fragments avant de les ajouter",
],
},
en: {
// NOT "chop coarsely" — a word-prefix extension of "chop" above (see
// `cook`'s comment for why that duplicates NER candidates).
synonyms: ["chop", "chops", "chopped", "chopping", "roughly chop", "coarsely chopped"],
utterances: [
"finely chop the onions",
"cut into small, uneven pieces with a knife",
"break the herbs down into small bits before adding them",
],
},
},
{
uid: "peel",
fr: {
synonyms: [
"éplucher",
"épluchez",
"épluché",
"épluchée",
"épluchées",
"épluchage",
"peler",
"pelez",
"pelé",
"pelée",
"pelées",
],
utterances: [
"éplucher les pommes de terre",
"retirer la peau des carottes avec un économe",
"ôter la pelure du fruit avant de le couper",
],
},
en: {
synonyms: ["peel", "peels", "peeled", "peeling", "pare", "pared", "paring"],
utterances: [
"peel the potatoes",
"remove the skin from the carrots with a peeler",
"take the skin off the fruit before cutting it",
],
},
},
{
uid: "mince",
fr: {
synonyms: [
"émincer",
"émincez",
"émincé",
"émincée",
"émincées",
"ciseler",
"ciselez",
"ciselé",
"ciselée",
"ciselées",
],
utterances: [
"émincer l'oignon en fines lamelles",
"couper en très fines tranches régulières",
"détailler en lamelles aussi fines que possible",
// Without this, a short clause naming a different vegetable —
// "Émincer les tomates" — scored just above `melt`'s confidence
// threshold instead (a training-set-composition side effect of
// adding utterances elsewhere in this same pass, found by the full
// regression suite). A second example anchored on a different noun
// widens `mince`'s own region enough to reclaim it.
"émincer les tomates en fines rondelles",
],
},
en: {
// NOT "mince finely" — a word-prefix extension of "mince" above (see
// `cook`'s comment for why that duplicates NER candidates).
synonyms: ["mince", "minces", "minced", "mincing", "thinly slice", "finely mince"],
utterances: [
"mince the onion into thin strips",
"cut into very thin, even slices",
"slice into strips as thin as possible",
],
},
},
{
uid: "mix",
fr: {
synonyms: [
"mélanger",
"mélangez",
"mélangé",
"mélangée",
"mélangées",
"mélange",
"brasser",
"brassez",
"amalgamer",
"amalgamez",
],
utterances: [
"mélanger tous les ingrédients dans un saladier",
"combiner le sucre et la farine ensemble",
"remuer jusqu'à obtenir une préparation homogène",
],
},
en: {
synonyms: [
"mix",
"mixes",
"mixed",
"mixing",
"combine",
"combined",
"blend",
"blended",
"blending",
"stir together",
],
utterances: [
"mix all the ingredients in a bowl",
"combine the sugar and flour together",
"stir until the mixture is smooth and even",
],
},
},
{
uid: "whisk",
fr: {
synonyms: [
"fouetter",
"fouettez",
"fouetté",
"fouettée",
"fouettées",
"au fouet",
"battre au fouet",
"monter au fouet",
],
utterances: [
"fouetter les œufs et le sucre",
"battre vigoureusement au fouet jusqu'à ce que ça blanchisse",
"travailler énergiquement pour incorporer de l'air au mélange",
// Without these, "Fouetter les blancs en neige" misclassified as
// `foldIn` — its own training utterance below also happens to say
// "les blancs en neige", and node-nlp's intent classifier leaned on
// that shared noun phrase over the actual verb. The exact phrase
// itself is needed (not just a paraphrase of it) — a longer,
// differently-worded utterance alone wasn't enough to outweigh
// `foldIn`'s own close phrasing.
"fouetter les blancs en neige",
"fouetter les blancs en neige jusqu'à ce qu'ils soient fermes",
],
},
en: {
synonyms: ["whisk", "whisks", "whisked", "whisking", "beat", "whip", "whipped", "whipping"],
utterances: [
"whisk the eggs and sugar",
"beat vigorously with a whisk until pale",
"work it briskly to whip air into the mixture",
"whisk the egg whites until stiff peaks form",
],
},
},
{
uid: "foldIn",
fr: {
synonyms: [
"incorporer",
"incorporez",
"incorporé",
"incorporée",
"incorporées",
// NOT "incorporer délicatement" — it's a superstring of "incorporer"
// above, so both would match the same text and hand
// `splitIntoClauses` two overlapping candidates for one mention
// (found via "Incorporer délicatement la farine" producing two
// duplicate matches instead of one).
"mélanger délicatement",
],
utterances: [
"incorporer délicatement les blancs en neige",
"ajouter en soulevant doucement la masse pour ne pas casser les bulles",
"mélanger tout doucement de bas en haut pour garder l'air emprisonné",
],
},
en: {
synonyms: ["fold in", "folds in", "folded in", "folding in", "gently fold", "fold gently"],
utterances: [
"gently fold in the beaten egg whites",
"add by gently lifting the batter so you don't knock the air out",
"very gently stir from the bottom up to keep the air trapped in",
],
},
},
{
uid: "setAside",
fr: {
synonyms: [
"réserver",
"réservez",
"réservé",
"réservée",
"réservées",
"mettre de côté",
"laisser de côté",
],
utterances: [
"réserver au frais en attendant",
"mettre de côté pour plus tard",
"laisser attendre sur le plan de travail pendant la préparation du reste",
],
},
en: {
synonyms: ["set aside", "sets aside", "setting aside", "set it aside", "reserve", "reserved"],
utterances: [
"set aside in the fridge for now",
"put it aside for later",
"let it wait on the counter while you prepare the rest",
],
},
},
{
uid: "season",
fr: {
synonyms: [
"assaisonner",
"assaisonnez",
"assaisonné",
"assaisonnée",
"assaisonnement",
"relever",
"relevez",
"épicer",
"épicez",
],
utterances: [
"assaisonner avec du sel et du poivre",
"rectifier le goût en ajoutant des épices",
"ajouter du sel selon votre goût avant de servir",
],
},
en: {
synonyms: ["season", "seasons", "seasoned", "seasoning", "spice it up", "add seasoning"],
utterances: [
"season with salt and pepper",
"adjust the taste by adding spices",
"add salt to taste before serving",
],
},
},
{
uid: "drain",
fr: {
synonyms: [
"égoutter",
"égouttez",
"égoutté",
"égouttée",
"égouttées",
"essorer",
"essorez",
"essoré",
"essorée",
],
utterances: [
"égoutter les pâtes dans une passoire",
"verser dans une passoire pour retirer l'eau de cuisson",
"laisser l'excédent d'eau s'écouler avant de servir",
],
},
en: {
synonyms: ["drain", "drains", "drained", "draining", "strain", "strained", "straining"],
utterances: [
"drain the pasta in a colander",
"pour into a colander to remove the cooking water",
"let the excess water run off before serving",
],
},
},
{
uid: "brown",
fr: {
synonyms: [
"faire revenir",
"faites revenir",
"faire dorer",
"faites dorer",
"colorer",
"colorez",
"faire colorer",
],
utterances: [
"faire revenir les oignons dans l'huile chaude",
"faire dorer la viande sur toutes les faces",
"saisir jusqu'à ce que la surface prenne une belle couleur caramel",
],
},
en: {
// Verb forms only (not bare "brown"), same reasoning the old regex
// doc comment gave — a bare "brown" false-positives on ingredient
// descriptions like "brown sugar"/"brown rice", which never get to
// the classifier since they're not step text, but keeping the
// synonym itself anchored costs nothing and stays consistent.
synonyms: ["browned", "browning"],
utterances: [
"brown the onions in hot oil",
"brown the meat on every side",
"sear until the surface turns a deep caramel color",
],
},
},
{
uid: "rest",
fr: {
synonyms: ["reposer", "laisser reposer", "laissez reposer", "temps de repos"],
utterances: [
"laisser reposer la pâte trente minutes",
"laisser la viande se détendre hors du four avant de la découper",
"attendre quelques minutes avant de servir pour que les jus se répartissent",
],
},
en: {
// Anchored to "let ... rest"/"rest for" rather than bare "rest",
// same false-positive reasoning as `brown` above ("the rest of the").
synonyms: ["let it rest", "let them rest", "resting for", "rested for", "resting time"],
utterances: [
"let the dough rest for thirty minutes",
"let the meat relax outside the oven before carving it",
"wait a few minutes before serving so the juices redistribute",
],
},
},
{
uid: "preheat",
fr: {
synonyms: [
"préchauffer",
"préchauffez",
"préchauffé",
"préchauffée",
// A pan already described as hot ("poêle chaude") implies it's
// been preheated, without the verb itself — the classic "Dans une
// poêle chaude, faire chauffer une noix de beurre" case (both
// `preheat` and `melt` in one instruction).
"poêle chaude",
"préchauffage",
],
utterances: [
"préchauffer le four à 180 degrés",
"mettre le four à chauffer avant d'y placer le plat",
"allumer le four à l'avance pour qu'il soit à température",
// A pan gets preheated too, not just an oven — without an example
// like this, "poêle" (which also appears throughout `panFry`'s own
// training utterances) biased the classifier toward `panFry` for
// any preheating clause that happens to mention a pan, found while
// testing against the classic "Préchauffer la poêle, puis faire
// fondre le beurre" case.
"préchauffer la poêle avant d'y verser l'huile",
"faire chauffer la poêle à vide quelques minutes",
// "poêle" + "feu vif" together still read as `panFry` (the act of
// actually cooking something in it) rather than `preheat` (getting
// it hot beforehand, nothing in it yet) without an example this
// close to that exact wording — found via "mettre la poêle sur feu
// vif" (no food mentioned at all) still classifying as panFry.
"mettre la poêle vide sur feu vif avant d'ajouter quoi que ce soit",
"mettre la poêle sur feu vif",
],
},
en: {
// NOT "preheating time" — a word-prefix extension of "preheating"
// above (see `cook`'s comment for why that duplicates NER candidates).
synonyms: ["preheat", "preheats", "preheated", "preheating", "hot pan"],
utterances: [
"preheat the oven to 180 degrees",
"turn the oven on to heat up before putting the dish in",
"switch the oven on ahead of time so it's up to temperature",
"preheat the pan before adding the oil",
"heat the empty pan for a few minutes first",
],
},
},
{
uid: "bake",
fr: {
synonyms: [
"cuire au four",
"cuisson au four",
"enfourner",
"enfournez",
"au four",
"enfourné",
"enfournée",
],
utterances: [
"enfourner pendant quarante-cinq minutes",
"mettre au four jusqu'à ce que ce soit doré",
"cuire dans le four préchauffé jusqu'à ce que la surface soit ferme",
],
},
en: {
// NOT "baked in the oven" — a word-prefix extension of "baked" above
// (see `cook`'s comment for why that duplicates NER candidates).
synonyms: ["bake", "bakes", "baked", "baking", "in the oven", "oven-baked"],
utterances: [
"bake for forty-five minutes",
"put it in the oven until golden",
"cook in the preheated oven until the surface is firm",
],
},
},
{
uid: "plate",
fr: {
// NOT "dressage de l'assiette" — a word-prefix extension of
// "dressage" above (see `cook`'s comment for why that duplicates NER
// candidates).
synonyms: ["dresser", "dressez", "dressage", "disposer dans l'assiette"],
utterances: [
"dresser harmonieusement dans les assiettes",
"disposer joliment sur l'assiette avant de servir",
"présenter avec soin au centre de l'assiette",
],
},
en: {
// NOT "plate up"/"plated nicely" — both are word-prefix extensions of
// "plate"/"plated" above (see `cook`'s comment for why that
// duplicates NER candidates).
synonyms: ["plate", "plates", "plated", "plating"],
utterances: [
"plate it up nicely",
"arrange it neatly on the plate before serving",
"present it carefully in the center of the plate",
],
},
},
{
uid: "coat",
fr: {
synonyms: [
"napper",
"nappez",
"nappé",
"nappée",
"nappées",
"nappage",
"enrober",
"enrobez",
"enrobé",
"enrobée",
"enrobées",
],
utterances: [
"napper le gâteau de chocolat fondu",
"recouvrir uniformément d'une fine couche de sauce",
"verser la sauce par-dessus pour bien enrober",
],
},
en: {
// NOT "coat evenly" — a word-prefix extension of "coat" above (see
// `cook`'s comment for why that duplicates NER candidates).
synonyms: ["coat", "coats", "coated", "coating", "dredge", "dredged", "dredging"],
utterances: [
"coat the cake with melted chocolate",
"cover evenly with a thin layer of sauce",
"pour the sauce over it so it's well covered",
],
},
},
];

View file

@ -84,75 +84,6 @@ async function assertTechStepsExist(ids: number[]): Promise<void> {
}
}
/** Throws `404 INGREDIENT_NOT_FOUND` if any id in `ids` doesn't match a reference `Ingredient` row — same shape as {@link assertTechStepsExist}, checking `input.ingredients[].ingredientId` instead. */
async function assertIngredientsExist(ids: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length === 0) return;
const found = await prisma.ingredient.findMany({
where: { id: { in: uniqueIds } },
select: { id: true },
});
const foundIds = new Set(found.map((ingredient) => ingredient.id));
const missing = uniqueIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new HttpError(
404,
ErrorCode.INGREDIENT_NOT_FOUND,
`Ingredient ids not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see loadVisibleStepOrThrow's catch comment
}
}
/** Throws `404 UNIT_NOT_FOUND` if any id in `ids` doesn't match a reference `Unit` row — same shape as {@link assertIngredientsExist}, checking `input.ingredients[].unitId` instead. */
async function assertUnitsExist(ids: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length === 0) return;
const found = await prisma.unit.findMany({
where: { id: { in: uniqueIds } },
select: { id: true },
});
const foundIds = new Set(found.map((unit) => unit.id));
const missing = uniqueIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new HttpError(
404,
ErrorCode.UNIT_NOT_FOUND,
`Unit ids not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see loadVisibleStepOrThrow's catch comment
}
}
/** Throws `404 UTENSIL_NOT_FOUND` if any id in `ids` doesn't match a reference `Utensil` row — same shape as {@link assertIngredientsExist}, checking `input.utensils[].utensilId` instead. */
async function assertUtensilsExist(ids: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length === 0) return;
const found = await prisma.utensil.findMany({
where: { id: { in: uniqueIds } },
select: { id: true },
});
const foundIds = new Set(found.map((utensil) => utensil.id));
const missing = uniqueIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new HttpError(
404,
ErrorCode.UTENSIL_NOT_FOUND,
`Utensil ids not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see loadVisibleStepOrThrow's catch comment
}
}
/**
* Renumbers every one of `stepId`'s `StepTechStep` rows' `order` by
* ascending `start` (nulls-still-possible legacy rows, see that model's
@ -194,20 +125,6 @@ export async function renumberStepTechSteps(
}
}
/** One ingredient/utensil mention the viewer themselves selected, ready to persist — see {@link applyManualCorrection}'s own doc comment for the "manual replaces all" semantics these are written under. */
interface ManualIngredientMention {
ingredientId: number;
quantity: number | null;
unitId: number | null;
start: number;
end: number;
}
interface ManualUtensilMention {
utensilId: number;
start: number;
end: number;
}
/**
* Applies a correction's *effect* on `stepId`'s real `StepTechStep`
* sequence, immediately not just recorded as a pending suggestion for
@ -225,28 +142,13 @@ interface ManualUtensilMention {
* `contextEnd` a correction only ever carries the tight span the user
* themselves selected/clicked, nothing wider to highlight around it.
* - `previousTechStepId` alone (remove, `correctedTechStepId: null`): the
* matching existing entry is deleted outright (cascading away any
* ingredient/utensil metadata attached to it, auto or manual nothing
* left to attach metadata to once the technique itself is gone). A
* no-op if none matches (nothing to remove).
*
* `metadata`, when given (only ever alongside a real `correctedTechStepId`
* enforced by `submitTechStepCorrectionSchema`, not re-checked here),
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil` row on
* this occurrence `source: "auto"` (the classifier's own detection) and
* any earlier `"manual"` set alike with the newly-submitted one. This is
* "le manuel remplace tout" (confirmed with the user): the resolved
* `order` this technique ends up at (whichever branch above produced it)
* is the same `techStepOrder` both metadata tables key on, so the same
* `deleteMany` + `createMany` pair below is correct whether this call just
* updated an existing row (which may already carry auto-detected
* metadata) or created a brand new one (nothing to delete yet a no-op
* `deleteMany`, not a special case).
* matching existing entry is deleted outright. A no-op if none matches
* (nothing to remove).
*
* Runs inside the same transaction {@link submitTechStepCorrection} uses
* for the audit-trail insert, so a request never leaves any of these
* effects (the permanent correction record, the live sequence change, the
* metadata replacement) only partially applied.
* for the audit-trail insert, so a request never leaves the two effects
* (the permanent correction record, the live sequence change) only
* partially applied.
*/
async function applyManualCorrection(
tx: Prisma.TransactionClient,
@ -254,7 +156,6 @@ async function applyManualCorrection(
span: { start: number; end: number },
previousTechStepId: number | null,
correctedTechStepId: number | null,
metadata?: { ingredients: ManualIngredientMention[]; utensils: ManualUtensilMention[] },
): Promise<void> {
const existing = await tx.stepTechStep.findMany({ where: { stepId } });
@ -271,12 +172,9 @@ async function applyManualCorrection(
: undefined;
if (correctedTechStepId !== null) {
const order = target
? target.order
: existing.reduce((max, row) => Math.max(max, row.order), -1) + 1;
if (target) {
await tx.stepTechStep.update({
where: { stepId_order: { stepId, order } },
where: { stepId_order: { stepId, order: target.order } },
data: {
techStepId: correctedTechStepId,
start: span.start,
@ -287,48 +185,18 @@ async function applyManualCorrection(
},
});
} else {
const nextOrder = existing.reduce((max, row) => Math.max(max, row.order), -1) + 1;
await tx.stepTechStep.create({
data: {
stepId,
techStepId: correctedTechStepId,
order,
order: nextOrder,
start: span.start,
end: span.end,
source: "manual",
},
});
}
if (metadata !== undefined) {
await tx.stepTechStepIngredient.deleteMany({ where: { stepId, techStepOrder: order } });
await tx.stepTechStepUtensil.deleteMany({ where: { stepId, techStepOrder: order } });
if (metadata.ingredients.length > 0) {
await tx.stepTechStepIngredient.createMany({
data: metadata.ingredients.map((ingredient) => ({
stepId,
techStepOrder: order,
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity,
unitId: ingredient.unitId,
start: ingredient.start,
end: ingredient.end,
source: "manual",
})),
});
}
if (metadata.utensils.length > 0) {
await tx.stepTechStepUtensil.createMany({
data: metadata.utensils.map((utensil) => ({
stepId,
techStepOrder: order,
utensilId: utensil.utensilId,
start: utensil.start,
end: utensil.end,
source: "manual",
})),
});
}
}
} else if (target) {
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
}
@ -364,12 +232,9 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
*
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` see
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
* `start`/`end` (the correction's own span, or any of
* `input.ingredients`/`input.utensils`' own spans) fall outside the
* step's current `description` (it may have been edited since the user
* last saw it). `404 TECH_STEP_NOT_FOUND`/`404 INGREDIENT_NOT_FOUND`/
* `404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND` if any referenced id
* doesn't exist.
* `start`/`end` fall outside the step's current `description` (it may
* have been edited since the user last saw it). `404 TECH_STEP_NOT_FOUND`
* if either tech-step id doesn't exist.
*/
export async function submitTechStepCorrection(
recipeId: number,
@ -381,32 +246,18 @@ export async function submitTechStepCorrection(
try {
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
const spans = [
{ start: input.start, end: input.end },
...(input.ingredients ?? []),
...(input.utensils ?? []),
];
for (const span of spans) {
if (span.start >= step.descriptionLength || span.end > step.descriptionLength) {
if (input.start >= step.descriptionLength || input.end > step.descriptionLength) {
throw new HttpError(
400,
ErrorCode.INVALID_CORRECTION_SPAN,
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
`Span [${input.start}, ${input.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
);
}
}
const techStepIds = [input.previousTechStepId, input.correctedTechStepId].filter(
(id): id is number => id !== null && id !== undefined,
);
await assertTechStepsExist(techStepIds);
await assertIngredientsExist((input.ingredients ?? []).map((i) => i.ingredientId));
await assertUnitsExist(
(input.ingredients ?? []).flatMap((i) =>
i.unitId !== null && i.unitId !== undefined ? [i.unitId] : [],
),
);
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
const { correction, techSteps } = await prisma.$transaction(async (tx) => {
const createdCorrection = await tx.stepTechStepCorrection.create({
@ -427,46 +278,12 @@ export async function submitTechStepCorrection(
{ start: input.start, end: input.end },
input.previousTechStepId ?? null,
input.correctedTechStepId ?? null,
input.ingredients === undefined && input.utensils === undefined
? undefined
: {
ingredients: (input.ingredients ?? []).map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity ?? null,
unitId: ingredient.unitId ?? null,
start: ingredient.start,
end: ingredient.end,
})),
utensils: (input.utensils ?? []).map((utensil) => ({
utensilId: utensil.utensilId,
start: utensil.start,
end: utensil.end,
})),
},
);
// 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" },
include: {
techStep: true,
ingredients: {
include: {
ingredient: {
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
},
unit: true,
},
},
utensils: { include: { utensil: true } },
},
include: { techStep: true },
});
return { correction: createdCorrection, techSteps: freshTechSteps };

View file

@ -45,29 +45,7 @@ function recipeInclude(viewerId: number) {
steps: {
orderBy: { order: "asc" },
include: {
techSteps: {
orderBy: { order: "asc" },
include: {
techStep: true,
// Same `allergies`/`diets` nesting as this function's own
// top-level `ingredients` include above — reused by
// `toIngredientView` so a mentioned ingredient resolves to the
// exact same `IngredientView` shape as the recipe's main
// ingredient list, not a second, thinner shape.
ingredients: {
include: {
ingredient: {
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
},
unit: true,
},
},
utensils: { include: { utensil: true } },
},
},
techSteps: { orderBy: { order: "asc" }, include: { techStep: true } },
},
},
diets: { include: { diet: true } },
@ -78,13 +56,11 @@ function recipeInclude(viewerId: number) {
type RecipeWithDetails = Prisma.RecipeGetPayload<{
include: ReturnType<typeof recipeInclude>;
}>;
/** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */
export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. */
export type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */
export function toUnitView(unit: UnitWithDetails): UnitView {
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. */
function toUnitView(unit: UnitWithDetails): UnitView {
return {
id: unit.id,
key: unit.key,
@ -93,8 +69,8 @@ export function toUnitView(unit: UnitWithDetails): UnitView {
};
}
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same flattening as `reference.service.ts`'s `getIngredients`. Exported — see {@link toUnitView}'s doc comment, same reuse by `shopping-list.service.ts`. */
export function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */
function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
return {
id: ingredient.id,
key: ingredient.key,
@ -167,8 +143,7 @@ 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 } = stepTechStep;
if (start === null || end === null) continue;
views.push({
techStep: { id: techStep.id, key: techStep.key },
@ -182,22 +157,6 @@ export function toStepTechStepViews(
// `StepTechStepView.source` to the frontend.
source: source === "manual" ? "manual" : "auto",
...(contextStart !== null && contextEnd !== null ? { contextStart, contextEnd } : {}),
ingredients: ingredients.map((stepTechStepIngredient) => ({
ingredient: toIngredientView(stepTechStepIngredient.ingredient),
quantity:
stepTechStepIngredient.quantity === null ? null : Number(stepTechStepIngredient.quantity),
unit: stepTechStepIngredient.unit === null ? null : toUnitView(stepTechStepIngredient.unit),
start: stepTechStepIngredient.start,
end: stepTechStepIngredient.end,
// Same narrowing posture as the technique's own `source` above.
source: stepTechStepIngredient.source === "manual" ? "manual" : "auto",
})),
utensils: utensils.map((stepTechStepUtensil) => ({
utensil: { id: stepTechStepUtensil.utensil.id, key: stepTechStepUtensil.utensil.key },
start: stepTechStepUtensil.start,
end: stepTechStepUtensil.end,
source: stepTechStepUtensil.source === "manual" ? "manual" : "auto",
})),
});
}
return views;
@ -592,22 +551,6 @@ async function createRecipeInternal(
contextStart: match.contextStart,
contextEnd: match.contextEnd,
order,
ingredients: {
create: match.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity,
unitId: ingredient.unitId,
start: ingredient.start,
end: ingredient.end,
})),
},
utensils: {
create: match.utensils.map((utensil) => ({
utensilId: utensil.utensilId,
start: utensil.start,
end: utensil.end,
})),
},
})),
},
})),

View file

@ -7,7 +7,6 @@ import {
getSources,
getTechSteps,
getUnits,
getUtensils,
} from "./reference.service.js";
/**
@ -56,13 +55,6 @@ referenceRouter.get(
}),
);
referenceRouter.get(
"/utensils",
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await getUtensils());
}),
);
referenceRouter.get(
"/sources",
wrapAsyncHandler(async (_req, res) => {

View file

@ -5,7 +5,6 @@ import type {
SourceView,
TechStepView,
UnitView,
UtensilView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
@ -86,19 +85,6 @@ export async function getTechSteps(): Promise<TechStepView[]> {
}
}
/**
* All reference cooking utensils, ordered by key (see {@link getDiets} for
* why) small, static list (see `reference-seed-data.ts`'s `UTENSILS`),
* same bare `id`/`key` shape as {@link getTechSteps}.
*/
export async function getUtensils(): Promise<UtensilView[]> {
try {
return await prisma.utensil.findMany({ orderBy: { key: "asc" } });
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
* Every implemented recipe source, ordered by name (not `key` unlike
* every other reference catalog, `name` here *is* the display string a

View file

@ -1,36 +0,0 @@
import { parseDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { ErrorCode, getShoppingListSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { getShoppingListForDate } from "./shopping-list.service.js";
/** Router mounted at `/shopping-list` in app.ts. */
export const shoppingListRouter = Router();
/**
* Returns the authenticated user's household's shopping list for the week
* covering `?date=` (`YYYY-MM-DD`) every ingredient line of every recipe
* planned that week, summed (see {@link getShoppingListForDate}). Always
* `200`, never `null` no household or nothing planned that week both
* come back as a normal `ShoppingListView` with an empty `items` array.
*/
shoppingListRouter.get(
"/",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = getShoppingListSchema.parse(req.query);
const date = parseDateOnly(input.date);
if (date === null) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a real calendar date: ${input.date}`,
);
}
const shoppingList = await getShoppingListForDate(res.locals.userProfile.houseId, date);
res.status(200).json(shoppingList);
}),
);

View file

@ -1,154 +0,0 @@
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
import type {
IngredientView,
ShoppingListItemView,
ShoppingListView,
UnitView,
} from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
/** Prisma `include` for a `Planning` query that needs, for every item, just enough of its recipe to compute a shopping list — `portions` (to scale `RecipeIngredient.quantity`) and the ingredient lines themselves, each resolved the same way `recipe.service.ts`'s own `recipeInclude` resolves them (so {@link toIngredientView}/{@link toUnitView} can be reused as-is). Deliberately narrower than a full `RecipeView` fetch — steps/diets/favorites are never read here. */
function shoppingListPlanningInclude() {
return {
items: {
include: {
recipe: {
select: {
portions: true,
ingredients: {
include: {
ingredient: {
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
},
unit: true,
},
},
},
},
},
},
} satisfies Prisma.PlanningInclude;
}
type PlanningWithIngredients = Prisma.PlanningGetPayload<{
include: ReturnType<typeof shoppingListPlanningInclude>;
}>;
/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. */
interface RunningTotal {
ingredient: IngredientView;
unit: UnitView;
quantity: number;
}
/**
* Sums every ingredient line across `items`, each scaled by that planning
* item's own portion count relative to its recipe's as-written yield
* (`RecipeIngredient.quantity × PlanningItem.portions / Recipe.portions`,
* see `PlanningItem.portions`'s doc comment in schema.prisma for why the
* two can differ). Grouped by `(ingredientId, unitId)` **not** just
* `ingredientId` since summing across units isn't implemented yet (see
* `ShoppingListItemView`'s doc comment): the same ingredient requested in
* two different units stays two separate lines rather than silently
* guessing a conversion. Pure/synchronous, factored out from
* {@link getShoppingListForDate} so the aggregation itself is testable
* without a database round-trip.
*/
function aggregateShoppingList(items: PlanningWithIngredients["items"]): ShoppingListItemView[] {
const totals = new Map<string, RunningTotal>();
for (const item of items) {
const scale = item.portions / item.recipe.portions;
for (const recipeIngredient of item.recipe.ingredients) {
const key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`;
const addedQuantity = Number(recipeIngredient.quantity) * scale;
const existing = totals.get(key);
if (existing) {
existing.quantity += addedQuantity;
} else {
totals.set(key, {
ingredient: toIngredientView(recipeIngredient.ingredient),
unit: toUnitView(recipeIngredient.unit),
quantity: addedQuantity,
});
}
}
}
// Deterministic order (by the ingredient's stable `key`, not its id —
// insertion order would otherwise depend on which recipe happened to be
// read first) — the frontend re-sorts by translated label/aisle for
// display, this is just so two identical plannings always produce the
// same JSON.
return [...totals.values()].sort((a, b) => a.ingredient.key.localeCompare(b.ingredient.key));
}
/**
* Builds the household's shopping list for the week covering `date`
* every ingredient line of every recipe planned that week, aggregated (see
* {@link aggregateShoppingList}). `date` is whatever the caller wants "that
* week" to mean, same convention as `planning.service.ts`'s
* `getPlanningForDate` (a caller-parsed `?date=`, not necessarily a
* Monday).
*
* Unlike `getPlanningForDate`, this **never** returns `null` no household
* and "no planning covers this week yet" both degrade to an empty `items`
* array on an otherwise normal `ShoppingListView` (the week's date range is
* always computable from `date` alone, even with nothing planned in it),
* rather than a separate "nothing to show" state the frontend would have to
* branch on.
*/
export async function getShoppingListForDate(
houseId: number | null,
date: DateTime,
): Promise<ShoppingListView> {
try {
const weekStart = getWeekStart(toDateOnly(date));
const weekFinish = weekStart.plus({ days: 6 });
const emptyList: ShoppingListView = {
startDate: weekStart.toJSDate().toISOString(),
finishDate: weekFinish.toJSDate().toISOString(),
items: [],
};
if (houseId === null) {
return emptyList;
}
// Same "covering range" lookup as getPlanningForDate — see that
// function's doc comment for why this compares against a UTC-midnight
// JS Date rather than `weekStart`/`weekFinish` directly.
const dateOnly = toDateOnly(date).toJSDate();
const planning = await prisma.planning.findFirst({
where: {
houseId,
startDate: { lte: dateOnly },
finishDate: { gte: dateOnly },
},
orderBy: { startDate: "desc" },
include: shoppingListPlanningInclude(),
});
if (!planning) {
return emptyList;
}
return {
startDate: planning.startDate.toISOString(),
finishDate: planning.finishDate.toISOString(),
items: aggregateShoppingList(planning.items),
};
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
}
}

View file

@ -27,7 +27,7 @@ import { RecipeSourceError } from "../../lib/recipe-sources/recipe-source-errors
import { getRecipeSource } from "../../lib/recipe-sources/recipe-source-registry.js";
import { getHouseSourceIds } from "../house/house.service.js";
import { createImportedRecipe } from "../recipe/recipe.service.js";
import { getIngredients, getUnits, getUtensils } from "../reference/reference.service.js";
import { getIngredients, getUnits } from "../reference/reference.service.js";
/**
* Browsing, previewing, and importing a household's *enabled* external
@ -190,14 +190,9 @@ export async function previewSourceItem(
unitCatalog,
adapter.locale,
);
const [ingredientViews, unitViews, utensilViews] = await Promise.all([
getIngredients(),
getUnits(),
getUtensils(),
]);
const [ingredientViews, unitViews] = await Promise.all([getIngredients(), getUnits()]);
const ingredientById = new Map(ingredientViews.map((view) => [view.id, view]));
const unitById = new Map(unitViews.map((view) => [view.id, view]));
const utensilById = new Map(utensilViews.map((view) => [view.id, view]));
// A source's raw ingredient lines aren't deduplicated by the matcher —
// two different lines (e.g. "Egg Yolks"/"Eggs") can resolve to the same
@ -236,30 +231,6 @@ export async function previewSourceItem(
// comment) — always the classifier's own live match,
// never a correction, so always "auto".
source: "auto",
ingredients: match.ingredients.flatMap((mention) => {
const ingredient = ingredientById.get(mention.ingredientId);
// Same drift guard as `techStep` above — an ingredientId
// the matcher resolved but that's since vanished from the
// catalog is dropped rather than shown with a hole in it.
if (!ingredient) return [];
return [
{
ingredient,
quantity: mention.quantity,
unit: mention.unitId !== null ? (unitById.get(mention.unitId) ?? null) : null,
start: mention.start,
end: mention.end,
// Same reasoning as this match's own `source` above — a draft preview only ever holds live classifier output.
source: "auto" as const,
},
];
}),
utensils: match.utensils.flatMap((mention) => {
const utensil = utensilById.get(mention.utensilId);
return utensil
? [{ utensil, start: mention.start, end: mention.end, source: "auto" as const }]
: [];
}),
},
]
: [];

View file

@ -6,8 +6,8 @@ import { renumberStepTechSteps } from "../modules/recipe/recipe-tech-step-correc
/**
* Recomputes every existing `Step`'s `"auto"`-sourced `StepTechStep`
* entries against the *current* classifier
* (`tech-step-matcher.ts`, delegating to `services/tech-step-intent-service`),
* the same way `updateRecipe` does when a user resaves a recipe through the UI
* (`tech-step-matcher.ts`/`tech-step-training-data.ts`), the same way
* `updateRecipe` does when a user resaves a recipe through the UI
* always `"fr"` (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`; there's
* no persisted per-recipe locale to recover for a step that already
* exists, so this matches real resave behavior exactly rather than

View file

@ -1,101 +0,0 @@
import { prisma } from "../db/prisma.js";
import { TECH_STEP_EVAL_DATASET } from "../lib/recipe-matching/tech-step-eval-dataset.js";
import {
computeTechStepMetrics,
type TechStepEvalOutcome,
} from "../lib/recipe-matching/tech-step-evaluator.js";
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
/**
* Candidate thresholds to sweep, `0.05` to `0.95` in `0.05` steps fine
* enough to find a good value without an unreasonable number of full
* `TECH_STEP_EVAL_DATASET` passes (each threshold only needs one
* {@link techStepClassifier.classifyClauses} call per eval case, not a
* retrain see this file's own doc comment for why).
*/
const CANDIDATE_THRESHOLDS = Array.from({ length: 19 }, (_, i) => Math.round((i + 1) * 5) / 100);
/**
* One-off maintainer tool for recalibrating `CONFIDENCE_THRESHOLD`
* (`tech-step-matcher.ts`) after a change to the underlying intent
* classifier most notably, the migration from `node-nlp` to
* `services/tech-step-intent-service` (spaCy): a different model produces a
* differently-shaped confidence score distribution, so a threshold tuned
* against the old classifier has no reason to still be the right cutoff for
* the new one.
*
* Reuses `techStepClassifier.classifyClauses` already public, and
* deliberately *not* threshold-applied (see that method's own doc comment)
* to get every eval case's raw `{anchorUid, intentUid, score}` per clause
* exactly once, then replays `_classifyClause`'s own decision rule
* (`intentUid` if confident enough, `anchorUid` otherwise) locally in this
* script for every candidate threshold. This is what makes a full sweep
* cheap: one classifier pass per eval case regardless of how many
* thresholds are being compared, rather than one full pass *per threshold*.
*
* Prints a threshold -> precision/recall/F1 table and the threshold that
* maximizes aggregate F1 does **not** edit `tech-step-matcher.ts` itself.
* A maintainer reads the table, updates `CONFIDENCE_THRESHOLD` by hand (with
* an updated doc comment recording what run/F1 the new value was calibrated
* against, same as the existing comment's own format), then re-runs
* `retrain-tech-steps.ts` to confirm the change clears `MIN_OVERALL_F1`.
*
* Usage:
*
* pnpm --filter api exec tsx src/scripts/calibrate-tech-step-threshold.ts
*/
async function calibrateTechStepThreshold(): Promise<void> {
console.info(`Classifying ${TECH_STEP_EVAL_DATASET.length} eval case(s)...`);
// One classifier pass per eval case, all clauses' raw verdicts kept
// alongside the case's own `expectedKeys` — reused for every candidate
// threshold in the loop below.
const casesWithClauses = await Promise.all(
TECH_STEP_EVAL_DATASET.map(async (evalCase) => ({
expectedKeys: evalCase.expectedKeys,
clauses: await techStepClassifier.classifyClauses(evalCase.description, evalCase.locale),
})),
);
console.info("\nthreshold precision recall f1");
let bestThreshold = CANDIDATE_THRESHOLDS[0] ?? 0;
let bestF1 = -1;
for (const threshold of CANDIDATE_THRESHOLDS) {
const outcomes: TechStepEvalOutcome[] = casesWithClauses.map(({ expectedKeys, clauses }) => {
const actualKeys = clauses
// Mirrors `_classifyClause`'s own decision rule exactly (see that
// method, `tech-step-matcher.ts`) — the classifier's own verdict
// when confident enough, otherwise its clause's NER anchor, `null`
// when neither applies (no keyword, no confident classification).
.map((clause) =>
clause.intentUid !== null && clause.score >= threshold
? clause.intentUid
: clause.anchorUid,
)
.filter((key): key is string => key !== null);
return { expectedKeys, actualKeys };
});
const { overall } = computeTechStepMetrics(outcomes);
console.info(
`${threshold.toFixed(2)} ${overall.precision.toFixed(3)} ${overall.recall.toFixed(3)} ${overall.f1.toFixed(3)}`,
);
if (overall.f1 > bestF1) {
bestF1 = overall.f1;
bestThreshold = threshold;
}
}
console.info(
`\nBest aggregate F1 ${bestF1.toFixed(3)} at threshold ${bestThreshold.toFixed(2)} — update CONFIDENCE_THRESHOLD in tech-step-matcher.ts by hand if this differs from the current value.`,
);
}
calibrateTechStepThreshold()
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err);
await prisma.$disconnect();
process.exit(1);
});

View file

@ -6,15 +6,14 @@ import { prisma } from "../db/prisma.js";
* comment) generated by `services/tech-step-llm-worker`'s scheduled
* jobs, from either a user correction or the worker's own low-confidence
* audit (`sourceType`). What a maintainer reads *before* hand-editing
* `services/tech-step-intent-service/intent_service/training_data.py` and
* running `retrain-tech-steps.ts` this script never writes anything,
* purely a read-only report to stdout:
* `tech-step-training-data.ts` and running `retrain-tech-steps.ts` this
* script never writes anything, purely a read-only report to stdout:
*
* pnpm --filter api exec tsx src/scripts/list-pending-training-suggestions.ts
*
* Grouped by technique key so every suggestion for the same entry in
* `training_data.py`'s `TECH_STEP_TRAINING_DATA` is read together, matching
* how that file itself is organized (one block per technique).
* `TECH_STEP_TRAINING_DATA` is read together, matching how that file
* itself is organized (one block per technique).
*/
async function listPendingTrainingSuggestions(): Promise<void> {
const suggestions = await prisma.techStepTrainingSuggestion.findMany({

View file

@ -28,15 +28,10 @@ function parseSuggestionIds(flag: "applied" | "rejected"): number[] {
* Maintainer workflow closing the loop on a training-corpus change (see
* this feature's plan document):
*
* 1. A maintainer has already hand-edited
* `services/tech-step-intent-service/intent_service/training_data.py`
* (informed by `list-pending-training-suggestions.ts`'s report),
* 1. A maintainer has already hand-edited `tech-step-training-data.ts`
* (informed by `list-pending-training-suggestions.ts`'s report), and
* decided which `TechStepTrainingSuggestion` ids they incorporated
* (`--applied=`) or explicitly discarded (`--rejected=`), **and
* restarted `tech-step-intent-service`** so it retrains from the
* edited corpus that service only ever trains once, at its own
* startup (see its README), so this script's eval gate below is
* meaningless against a service still running the old corpus.
* (`--applied=`) or explicitly discarded (`--rejected=`).
* 2. This script re-runs the F1 regression gate
* ({@link runTechStepEvalSuite} against {@link MIN_OVERALL_F1})
* refuses to backfill at all if the edited corpus scores worse than

View file

@ -9,44 +9,21 @@ import { registerAllRecipeSources } from "./sources/index.js";
// doesn't happen inside app.ts/createServer() itself.
registerAllRecipeSources();
/**
* Trains the tech-step classifier (a `POST /v1/train` round-trip per locale
* to `services/tech-step-intent-service` see
* `TechStepClassifierService.warmUp`) before accepting any traffic, so the
* first real recipe save/preview isn't the one stuck waiting for it.
*
* Retried with exponential backoff: in Docker Compose, `app`'s own
* `depends_on: tech-step-intent-service: condition: service_healthy`
* (`docker-compose.yml`) already means that service is up by the time this
* runs, but native dev (`pnpm dev:api`, no Compose ordering at all) can
* easily start this before the intent service has finished loading its
* spaCy models a transient connection failure here shouldn't need a
* manual restart. Still non-fatal after every attempt is exhausted: the
* *next* real call retries training itself (see `_ensureTrained`'s own
* retry-on-failure comment), same graceful-degrade posture as before this
* retry loop existed.
*/
async function warmUpTechStepClassifier(): Promise<void> {
const maxAttempts = 5;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// Trains the tech-step classifier (and pays node-nlp's own one-time lazy
// setup cost — see `TechStepClassifierService.warmUp`) before accepting
// any traffic, so the first real recipe save/preview isn't the one stuck
// waiting several seconds for it.
try {
await techStepClassifier.warmUp();
return;
} catch (err) {
if (attempt === maxAttempts) {
logger.error("Tech-step classifier warm-up failed after retries", {
// Not fatal to startup — a failed warm-up just means the *next* call
// retries training itself (see `_ensureTrained`'s own retry-on-failure
// comment), same graceful-degrade posture as everywhere else training
// failures surface. Still worth a loud log: this shouldn't normally fail.
logger.error("Tech-step classifier warm-up failed", {
error: err instanceof Error ? err.message : String(err),
attempts: attempt,
});
return;
}
const delayMs = 1000 * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
await warmUpTechStepClassifier();
const server = createServer();

50
apps/api/src/types/node-nlp.d.ts vendored Normal file
View file

@ -0,0 +1,50 @@
/**
* Minimal ambient typing for `node-nlp` (no official/DefinitelyTyped types
* exist for it) declares only the `NlpManager` surface
* `tech-step-matcher.ts` actually calls, verified against the real
* package (v4.27.0) rather than the library's full documented API, which
* this repo doesn't use the rest of.
*/
declare module "node-nlp" {
/** Constructor options this repo passes — `NlpManager` accepts more, only what's used here is typed. */
export interface NlpManagerOptions {
languages?: string[];
forceNER?: boolean;
nlu?: { log?: boolean };
ner?: { threshold?: number };
/** Defaults to `true` — persists the trained model to `modelFileName` (default `model.nlp`, in `process.cwd()`). See `tech-step-matcher.ts`'s own constructor comment for why this repo always sets it `false`. */
autoSave?: boolean;
/** Defaults to `true` — loads from `modelFileName` instead of training fresh if that file already exists. Always `false` here, same reasoning as `autoSave`. */
autoLoad?: boolean;
}
/** One entity `NlpManager.process`'s result reports — see `tech-step-matcher.ts`'s own `NerEntity` for the subset this repo reads. */
export interface NlpEntity {
entity: string;
start: number;
end: number;
type: string;
accuracy?: number;
sourceText?: string;
}
/** `NlpManager.process`'s result — trimmed to the fields this repo reads (the real object carries many more). */
export interface NlpProcessResult {
intent: string;
score: number;
entities: NlpEntity[];
}
export class NlpManager {
public constructor(options?: NlpManagerOptions);
public addNamedEntityText(
entityName: string,
optionName: string,
languages: string[],
texts: string[],
): void;
public addDocument(locale: string, utterance: string, intent: string): void;
public train(): Promise<void>;
public process(locale: string, text: string): Promise<NlpProcessResult>;
}
}

View file

@ -1,40 +0,0 @@
import { techStepClassifier } from "../src/lib/recipe-matching/tech-step-matcher.js";
import { resetDatabase } from "./reset-db.js";
/**
* Mocha root hook plugin (see `.mocharc.json`'s `require`) runs once
* before every test file's own suites, regardless of load order.
*
* Warms up `techStepClassifier` here resolving the `TechStep.key -> id`
* lookup from the DB (see `TechStepClassifierService._loadTechStepIds`)
* instead of leaving it to happen lazily on whichever test file Mocha
* happens to load first, same as `server.ts` does before the real server
* ever accepts traffic. Fast by itself (one DB query, one HTTP call to
* `services/tech-step-intent-service`): that service now trains itself
* entirely at its own process startup (see its own README), so unlike
* before this migration, nothing here waits on a slow training pass CI's
* own "wait for `/health`" step (`.github/workflows/ci.yml`) is what
* ensures that service is already fully trained before `pnpm --filter api
* test` even starts.
*
* `resetDatabase()` runs first, deliberately: id resolution needs
* `TechStep` rows, and a freshly-migrated (never-seeded) test database has
* none yet. Every per-test `beforeEach` in this suite already calls
* `resetDatabase()` again before its own test, which is a no-op
* duplication of effort but not a correctness problem: `TRUNCATE ...
* RESTART IDENTITY` plus deterministic re-seeding (`seedReferenceData`)
* assigns the exact same ids every time, so the `uid -> id` map memoized
* here from this first reset stays valid for every reset after it.
*/
export const mochaHooks = {
// biome-ignore lint/suspicious/noExplicitAny: Mocha's root hook `this` (a Context with `.timeout()`) isn't typed without @types/mocha (not a dependency here) — same untyped-`this` shape already used in tech-step-worker.routes.test.ts.
async beforeAll(this: any): Promise<void> {
// A little more generous than Mocha's normal 10s per-test default
// (`.mocharc.json`) purely for a slower/contended CI runner's first
// network round-trip to `services/tech-step-intent-service` — not
// because anything here waits on training anymore.
this.timeout(30000);
await resetDatabase();
await techStepClassifier.warmUp();
},
};

View file

@ -3,7 +3,6 @@ import { expect } from "chai";
import { prisma } from "../../src/db/prisma.js";
import {
extractQuantity,
findIngredientMentions,
type IngredientMatchEntry,
loadIngredientCatalog,
loadUnitCatalog,
@ -267,100 +266,6 @@ describe("ingredient-matcher", () => {
});
});
describe("findIngredientMentions", () => {
const butter: IngredientMatchEntry = { ingredientId: 1, label: "Butter" };
const flour: IngredientMatchEntry = { ingredientId: 2, label: "Flour" };
const egg: IngredientMatchEntry = { ingredientId: 3, label: "Egg" };
const catalog = [butter, flour, egg];
const gram: UnitMatchEntry = { unitId: 1, synonyms: ["g", "gram", "grams"] };
const unitCatalog = [gram];
it("finds a single mention with no quantity or unit", () => {
const text = "melt the butter";
const mentions = findIngredientMentions(text, catalog, unitCatalog);
expect(mentions).to.have.length(1);
const [mention] = mentions;
expect(mention?.ingredientId).to.equal(butter.ingredientId);
expect(text.slice(mention?.start, mention?.end)).to.equal("butter");
expect(mention?.quantity).to.equal(null);
expect(mention?.unitId).to.equal(null);
});
it('resolves a quantity and unit glued directly to the ingredient ("200g butter")', () => {
const text = "add 200g butter";
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
expect(mention?.ingredientId).to.equal(butter.ingredientId);
expect(mention?.quantity).to.equal(200);
expect(mention?.unitId).to.equal(gram.unitId);
});
it("finds several mentions in reading order, non-overlapping", () => {
const text = "melt the butter then add the flour and an egg";
const mentions = findIngredientMentions(text, catalog, unitCatalog);
expect(mentions.map((mention) => mention.ingredientId)).to.deep.equal([
butter.ingredientId,
flour.ingredientId,
egg.ingredientId,
]);
});
it("is case- and accent-insensitive", () => {
const text = "MELT THE BUTTER";
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
expect(mention?.ingredientId).to.equal(butter.ingredientId);
});
it("ignores an unrelated number earlier in the text (e.g. an oven temperature)", () => {
const text = "preheat to 180 degrees then add the egg";
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
expect(mention?.ingredientId).to.equal(egg.ingredientId);
expect(mention?.quantity).to.equal(null);
});
it("returns an empty array when nothing in the catalog is mentioned", () => {
expect(findIngredientMentions("stir well", catalog, unitCatalog)).to.deep.equal([]);
});
it("returns an empty array for empty text", () => {
expect(findIngredientMentions("", catalog, unitCatalog)).to.deep.equal([]);
});
describe("locale: fr", () => {
const beurre: IngredientMatchEntry = { ingredientId: 10, label: "Beurre" };
const farine: IngredientMatchEntry = { ingredientId: 11, label: "Farine" };
const frCatalog = [beurre, farine];
const gramme: UnitMatchEntry = { unitId: 40, synonyms: ["g", "gr", "gramme", "grammes"] };
const cuillereASoupe: UnitMatchEntry = {
unitId: 41,
synonyms: ["cuillère à soupe", "cuillères à soupe", "càs"],
};
const frUnitCatalog = [gramme, cuillereASoupe];
it("resolves a quantity and unit before the ingredient, connected by 'de'", () => {
const text = "faire fondre 50g de beurre";
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
expect(mention?.ingredientId).to.equal(beurre.ingredientId);
expect(mention?.quantity).to.equal(50);
expect(mention?.unitId).to.equal(gramme.unitId);
expect(text.slice(mention?.start, mention?.end)).to.equal("beurre");
});
it('resolves a multi-word unit connected by "d\'"', () => {
const text = "ajouter 2 cuillères à soupe de farine";
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
expect(mention?.ingredientId).to.equal(farine.ingredientId);
expect(mention?.quantity).to.equal(2);
expect(mention?.unitId).to.equal(cuillereASoupe.unitId);
});
it("is accent-insensitive", () => {
const text = "FAIRE FONDRE LE BEURRE";
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
expect(mention?.ingredientId).to.equal(beurre.ingredientId);
});
});
});
describe("loadIngredientCatalog / loadUnitCatalog", () => {
beforeEach(async () => {
await resetDatabase();

View file

@ -38,9 +38,8 @@ describe("recipe-translation", () => {
// `translateRecipeSteps` now goes through `techStepClassifier` (a
// trained model, not a pure regex test against a caller-supplied
// mapping list — see `tech-step-matcher.ts`), so these tests exercise
// the real training corpus (`services/tech-step-intent-service`'s
// `training_data.py`) against a real `TechStep` catalog rather than
// synthetic fixtures — same posture
// the real training corpus (`tech-step-training-data.ts`) against a real
// `TechStep` catalog rather than synthetic fixtures — same posture
// `tech-step-matcher.test.ts`'s own `techStepClassifier` describe block
// takes, for the same reason.
describe("translateRecipeSteps", () => {

View file

@ -118,16 +118,13 @@ describe("tech-step-matcher", () => {
// `techStepClassifier` is the one shared singleton (see
// tech-step-matcher.ts's own doc comment on why) — these tests
// exercise it against the real training corpus
// (`services/tech-step-intent-service`'s `training_data.py`) and the
// real seeded `TechStep` catalog, rather than synthetic injectable
// fixtures the old regex-based `matchTechStepSpans(description,
// mappings)` allowed. Every call round-trips over HTTP to a real,
// locally running `services/tech-step-intent-service` (see that
// service's own README and `apps/api/.env.test`) — that service trains
// itself once at its own startup (`test-support/mocha-root-hooks.ts`'s
// root hook doesn't wait on it, CI's own "wait for /health" step
// already does), so calls here are just a normal HTTP round-trip,
// comfortably inside this suite's default 10s timeout (.mocharc.json).
// (`tech-step-training-data.ts`) and the real seeded `TechStep`
// catalog, rather than synthetic injectable fixtures the old
// regex-based `matchTechStepSpans(description, mappings)` allowed.
// Training + node-nlp's own one-time per-language setup can take a
// few seconds on the very first call in the whole suite (subsequent
// calls reuse the same trained model and are fast) — comfortably
// inside this suite's default 10s timeout (.mocharc.json).
let simmerId: number;
let cookId: number;
let bakeId: number;
@ -135,19 +132,10 @@ describe("tech-step-matcher", () => {
let meltId: number;
let boilId: number;
let chopId: number;
// Real seeded catalog entries that also happen to be mentioned by
// several fixtures below now that `matchTechStepSpans` also resolves
// ingredient/utensil metadata — see `matchTechStepSpans`'s own describe
// block for where each of these gets used.
let panId: number;
let butterId: number;
let onionId: number;
let walnutsId: number;
beforeEach(async () => {
await resetDatabase();
const [simmer, cook, bake, preheat, melt, boil, chop, pan, butter, onion, walnuts] =
await Promise.all([
const [simmer, cook, bake, preheat, melt, boil, chop] = await Promise.all([
prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "cook" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "bake" } }),
@ -155,15 +143,6 @@ describe("tech-step-matcher", () => {
prisma.techStep.findFirstOrThrow({ where: { key: "melt" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "boil" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "chop" } }),
prisma.utensil.findFirstOrThrow({ where: { key: "pan" } }),
prisma.ingredient.findFirstOrThrow({ where: { key: "butter" } }),
prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } }),
// "Noix" (walnuts) — turns out to also be a real seeded ingredient
// label, and "noix" is literally the French word for "a pat of
// butter" ("une noix de beurre") used in one of the fixtures
// below, so it's a genuine (if slightly comical) second match
// alongside "beurre" in that clause, not a fixture bug.
prisma.ingredient.findFirstOrThrow({ where: { key: "walnuts" } }),
]);
simmerId = simmer.id;
cookId = cook.id;
@ -172,10 +151,6 @@ describe("tech-step-matcher", () => {
meltId = melt.id;
boilId = boil.id;
chopId = chop.id;
panId = pan.id;
butterId = butter.id;
onionId = onion.id;
walnutsId = walnuts.id;
});
after(async () => {
@ -277,15 +252,7 @@ describe("tech-step-matcher", () => {
const text = "Faire mijoter à feu doux";
const result = await techStepClassifier.matchTechStepSpans(text, "fr");
expect(result).to.deep.equal([
{
techStepId: simmerId,
start: 6,
end: 13,
contextStart: 0,
contextEnd: text.length,
ingredients: [],
utensils: [],
},
{ techStepId: simmerId, start: 6, end: 13, contextStart: 0, contextEnd: text.length },
]);
expect(text.slice(6, 13).toLowerCase()).to.equal("mijoter");
});
@ -335,12 +302,6 @@ describe("tech-step-matcher", () => {
end: 21,
contextStart: 0,
contextEnd: 22,
// "poêle" (the pan) sits inside this very clause — a separate
// utensil mention from `preheat`'s own "poêle chaude" keyword
// span above, found by the intent service's *other* PhraseMatcher
// (see `IntentServiceEntity.kind`).
ingredients: [],
utensils: [{ utensilId: panId, start: 9, end: 14 }],
});
expect(result[1]).to.deep.equal({
techStepId: meltId,
@ -348,15 +309,6 @@ describe("tech-step-matcher", () => {
end: 37,
contextStart: 22,
contextEnd: text.length,
// Two mentions in this clause: "noix" (walnuts — also a real
// seeded ingredient, and literally the French word this phrase
// uses for "a pat of [butter]") *and* "beurre" itself, in
// reading order.
ingredients: [
{ ingredientId: walnutsId, start: 42, end: 46, quantity: null, unitId: null },
{ ingredientId: butterId, start: 50, end: 56, quantity: null, unitId: null },
],
utensils: [],
});
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(
@ -378,15 +330,6 @@ describe("tech-step-matcher", () => {
end: text.length,
contextStart: 0,
contextEnd: text.length,
// "beurre" and "poêle" are both mentioned in this same
// anchor-less clause (there's no literal `melt` keyword here at
// all — the whole point of this test, see its own title) —
// still resolved, since ingredient/utensil scanning doesn't
// depend on the clause having a technique anchor of its own.
ingredients: [
{ ingredientId: butterId, start: 18, end: 24, quantity: null, unitId: null },
],
utensils: [{ utensilId: panId, start: 45, end: 50 }],
},
]);
});
@ -395,33 +338,10 @@ describe("tech-step-matcher", () => {
const text = "Chop the onions finely";
const result = await techStepClassifier.matchTechStepSpans(text, "en");
expect(result).to.deep.equal([
{
techStepId: chopId,
start: 0,
end: 4,
contextStart: 0,
contextEnd: text.length,
ingredients: [
{ ingredientId: onionId, start: 9, end: 15, quantity: null, unitId: null },
],
utensils: [],
},
{ techStepId: chopId, start: 0, end: 4, contextStart: 0, contextEnd: text.length },
]);
expect(text.slice(0, 4)).to.equal("Chop");
});
// Quantity+unit extraction itself (the leading-number-before-a-mention
// heuristic) is covered in full, deterministically, by
// `findIngredientMentions`'s own tests (`ingredient-matcher.test.ts`)
// — deliberately not re-exercised here through a brand-new invented
// sentence: a novel combination of words the real `textcat` (trained
// on a fixed, finite corpus, see `training_data.py`) has never seen
// together can land on a confidently-wrong technique for reasons
// that have nothing to do with this file's own logic, making such a
// test flaky against corpus/threshold changes rather than a
// trustworthy regression guard. The two tests above/below already
// demonstrate technique+ingredient+utensil co-occurring in one
// clause using sentences already proven reliable by this suite.
});
});
});

View file

@ -25,24 +25,6 @@ async function techStepId(key: string): Promise<number> {
return techStep.id;
}
/** Same as {@link techStepId}, for a reference `Ingredient`. */
async function ingredientId(key: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
return ingredient.id;
}
/** Same as {@link techStepId}, for a reference `Unit`. */
async function unitId(key: string): Promise<number> {
const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
return unit.id;
}
/** Same as {@link techStepId}, for a reference `Utensil`. */
async function utensilId(key: string): Promise<number> {
const utensil = await prisma.utensil.findFirstOrThrow({ where: { key } });
return utensil.id;
}
describe("Recipe tech-step corrections", () => {
const app = createApp();
@ -97,7 +79,7 @@ describe("Recipe tech-step corrections", () => {
const { agent, profileId } = await signup();
// "Faire mijoter la sauce." names no technique the classifier itself
// registers a bare-word anchor for at this exact span in isolation
// (see services/tech-step-intent-service's training_data.py) — irrelevant here either way,
// (see tech-step-training-data.ts) — irrelevant here either way,
// since this test's whole point is the *manual* addition, not
// whatever the classifier does or doesn't auto-detect for it.
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
@ -116,14 +98,7 @@ describe("Recipe tech-step corrections", () => {
// away — not just the permanent audit record above (see
// `applyManualCorrection`, `recipe-tech-step-correction.service.ts`).
expect(res.body.techSteps).to.deep.equal([
{
techStep: { id: simmerId, key: "simmer" },
start: 6,
end: 13,
source: "manual",
ingredients: [],
utensils: [],
},
{ techStep: { id: simmerId, key: "simmer" }, start: 6, end: 13, source: "manual" },
]);
});
@ -149,14 +124,7 @@ describe("Recipe tech-step corrections", () => {
// Still exactly one entry — the relabel updated the existing row
// rather than adding a second one alongside it.
expect(res.body.techSteps).to.deep.equal([
{
techStep: { id: boilId, key: "boil" },
start: 6,
end: 13,
source: "manual",
ingredients: [],
utensils: [],
},
{ techStep: { id: boilId, key: "boil" }, start: 6, end: 13, source: "manual" },
]);
});
@ -277,244 +245,6 @@ describe("Recipe tech-step corrections", () => {
});
});
describe("POST /recipes/:id/steps/:stepId/corrections — ingredients/utensils metadata", () => {
it("attaches manually-selected ingredients and utensils to a corrected technique", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const butterId = await ingredientId("butter");
const gramId = await unitId("gram");
const panId = await utensilId("pan");
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: simmerId,
ingredients: [{ ingredientId: butterId, quantity: 50, unitId: gramId, start: 0, end: 6 }],
utensils: [{ utensilId: panId, start: 14, end: 23 }],
});
expect(res.status).to.equal(201);
expect(res.body.techSteps).to.deep.equal([
{
techStep: { id: simmerId, key: "simmer" },
start: 6,
end: 13,
source: "manual",
ingredients: [
{
ingredient: res.body.techSteps[0].ingredients[0].ingredient,
quantity: 50,
unit: res.body.techSteps[0].ingredients[0].unit,
start: 0,
end: 6,
source: "manual",
},
],
utensils: [
{
utensil: res.body.techSteps[0].utensils[0].utensil,
start: 14,
end: 23,
source: "manual",
},
],
},
]);
expect(res.body.techSteps[0].ingredients[0].ingredient.id).to.equal(butterId);
expect(res.body.techSteps[0].ingredients[0].unit.id).to.equal(gramId);
expect(res.body.techSteps[0].utensils[0].utensil).to.deep.equal({ id: panId, key: "pan" });
});
it("attaches an ingredient with no quantity/unit (both omitted)", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const butterId = await ingredientId("butter");
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: simmerId,
ingredients: [{ ingredientId: butterId, start: 0, end: 6 }],
});
expect(res.status).to.equal(201);
expect(res.body.techSteps[0].ingredients[0].quantity).to.equal(null);
expect(res.body.techSteps[0].ingredients[0].unit).to.equal(null);
});
it("replaces both auto-detected and previously-manual metadata on the same occurrence — never accumulates", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const boilId = await techStepId("boil");
const butterId = await ingredientId("butter");
const carrotId = await ingredientId("carrot");
const panId = await utensilId("pan");
const saucepanId = await utensilId("saucepan");
// First correction creates the occurrence (order 0) — simulate an
// auto-detected ingredient already sitting on it, exactly as
// tech-step-matcher.ts would have written one at save time (bypassed
// here for a deterministic fixture, not dependent on the real
// classifier's own output for this text).
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
await prisma.stepTechStepIngredient.create({
data: {
stepId,
techStepOrder: 0,
ingredientId: butterId,
start: 0,
end: 6,
source: "auto",
},
});
await prisma.stepTechStepUtensil.create({
data: { stepId, techStepOrder: 0, utensilId: panId, start: 14, end: 23, source: "auto" },
});
// Second correction — relabels the technique *and* submits a whole
// new, disjoint metadata set.
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
previousTechStepId: simmerId,
correctedTechStepId: boilId,
ingredients: [{ ingredientId: carrotId, start: 0, end: 6 }],
utensils: [{ utensilId: saucepanId, start: 14, end: 23 }],
});
expect(res.status).to.equal(201);
expect(res.body.techSteps).to.have.length(1);
// Neither the auto-detected butter/pan nor an empty leftover row
// survive — only the freshly-submitted carrot/saucepan.
expect(
res.body.techSteps[0].ingredients.map(
(i: { ingredient: { id: number } }) => i.ingredient.id,
),
).to.deep.equal([carrotId]);
expect(
res.body.techSteps[0].utensils.map((u: { utensil: { id: number } }) => u.utensil.id),
).to.deep.equal([saucepanId]);
});
it("leaves existing metadata untouched when ingredients/utensils are omitted from the request", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const boilId = await techStepId("boil");
const butterId = await ingredientId("butter");
await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: simmerId,
ingredients: [{ ingredientId: butterId, start: 0, end: 6 }],
});
// Relabels the technique again, but says nothing about metadata at all.
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
previousTechStepId: simmerId,
correctedTechStepId: boilId,
});
expect(res.status).to.equal(201);
expect(res.body.techSteps[0].ingredients).to.have.length(1);
expect(res.body.techSteps[0].ingredients[0].ingredient.id).to.equal(butterId);
});
it("rejects metadata submitted alongside correctedTechStepId: null with 400 VALIDATION_ERROR", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const butterId = await ingredientId("butter");
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
previousTechStepId: simmerId,
correctedTechStepId: null,
ingredients: [{ ingredientId: butterId, start: 0, end: 6 }],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: await techStepId("simmer"),
ingredients: [{ ingredientId: 999_999, start: 0, end: 6 }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
});
it("rejects an unknown unitId with 404 UNIT_NOT_FOUND", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: await techStepId("simmer"),
ingredients: [
{ ingredientId: await ingredientId("butter"), unitId: 999_999, start: 0, end: 6 },
],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND);
});
it("rejects an unknown utensilId with 404 UTENSIL_NOT_FOUND", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: await techStepId("simmer"),
utensils: [{ utensilId: 999_999, start: 0, end: 6 }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.UTENSIL_NOT_FOUND);
});
it("rejects a metadata span past the end of the step's description with 400 INVALID_CORRECTION_SPAN", async () => {
const { agent, profileId } = await signup();
const description = "Court.";
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 0,
end: description.length,
correctedTechStepId: await techStepId("simmer"),
ingredients: [
{ ingredientId: await ingredientId("butter"), start: 0, end: description.length + 10 },
],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.INVALID_CORRECTION_SPAN);
});
});
describe("GET /recipes/:id/steps/:stepId/corrections", () => {
it("returns every correction submitted for the step, most recent first", async () => {
const { agent, profileId } = await signup();

View file

@ -3,7 +3,7 @@ import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import { seedReferenceData, TECH_STEPS, UTENSILS } from "../src/db/reference-seed-data.js";
import { seedReferenceData } from "../src/db/reference-seed-data.js";
import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
import {
clearRecipeSources,
@ -137,9 +137,7 @@ describe("Reference data", () => {
const res = await request(app).get("/reference/tech-steps");
expect(res.status).to.equal(200);
// `TECH_STEPS.length` (reference-seed-data.ts), not a hardcoded
// number — this catalog has grown since (26 -> 74) and will again.
expect(res.body).to.have.length(TECH_STEPS.length);
expect(res.body).to.have.length(26);
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer");
expect(res.body[0]).to.have.keys(["id", "key"]);
});
@ -157,32 +155,7 @@ describe("Reference data", () => {
await seedReferenceData(prisma);
const res = await request(app).get("/reference/tech-steps");
expect(res.body).to.have.length(TECH_STEPS.length);
});
});
describe("GET /reference/utensils", () => {
it("returns the seeded utensils, no session required", async () => {
const res = await request(app).get("/reference/utensils");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(UTENSILS.length);
expect(res.body.map((u: { key: string }) => u.key)).to.include("pan");
expect(res.body[0]).to.have.keys(["id", "key"]);
});
it("orders utensils alphabetically by key", async () => {
const res = await request(app).get("/reference/utensils");
const keys = res.body.map((u: { key: string }) => u.key);
expect(keys).to.deep.equal([...keys].sort());
});
it("reseeding is idempotent — no duplicate utensils", async () => {
await seedReferenceData(prisma);
const res = await request(app).get("/reference/utensils");
expect(res.body).to.have.length(UTENSILS.length);
expect(res.body).to.have.length(26);
});
});

View file

@ -1,336 +0,0 @@
import type { DateTime } from "@batch-cooking/date-tools";
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { TEST_REFERENCE_DATE } from "../test-support/reference-date.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** The fixed test "today", as the `YYYY-MM-DD` string `GET /shopping-list`'s `?date=` expects. */
function today(): string {
return isoDate(TEST_REFERENCE_DATE);
}
/** `toISODate()` only returns `null` for an invalid `DateTime` — never the case for the always-valid values built in this file. */
function isoDate(date: DateTime): string {
const iso = date.toISODate();
if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper");
return iso;
}
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helper as `recipe.test.ts`. */
async function ingredientId(key: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
return ingredient.id;
}
/** Resolves a reference unit's id by its `reference-seed-data.ts` uid — same helper as `recipe.test.ts`. */
async function unitId(key: string): Promise<number> {
const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
return unit.id;
}
describe("Shopping list", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /shopping-list", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a missing date with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/shopping-list");
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects a malformed date with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/shopping-list").query({ date: "not-a-date" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects a date shaped right but calendarially impossible with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/shopping-list").query({ date: "2026-02-30" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("returns an empty list when the profile has no household", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.deep.equal([]);
});
it("returns an empty list when the household has no planning covering that date", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
await agent.post("/house").send({ name: "Chez moi" });
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.deep.equal([]);
});
it("sums one recipe's ingredient across two planning slots, scaled by each slot's own portions", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const tomatoId = await ingredientId("tomato");
const gramId = await unitId("gram");
// Written for 2 portions, 100g tomato — planned twice this week at
// 4 portions each, so the shopping list should show 100 × (4/2) × 2
// = 400g, not the raw 200g the recipe itself lists.
const recipe = await prisma.recipe.create({
data: {
name: "Salade de tomates",
authorId,
portions: 2,
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
},
});
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: [
{
planningId: planning.id,
weekDay: "lundi",
meal: "dejeuner",
recipeId: recipe.id,
portions: 4,
},
{
planningId: planning.id,
weekDay: "mercredi",
meal: "diner",
recipeId: recipe.id,
portions: 4,
},
],
});
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.have.length(1);
expect(res.body.items[0].ingredient.key).to.equal("tomato");
expect(res.body.items[0].unit.key).to.equal("gram");
expect(res.body.items[0].quantity).to.equal(400);
});
it("sums the same ingredient across two different recipes sharing a unit", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const onionId = await ingredientId("onion");
const gramId = await unitId("gram");
const recipeA = await prisma.recipe.create({
data: {
name: "Soupe à l'oignon",
authorId,
portions: 4,
ingredients: { create: [{ ingredientId: onionId, quantity: 200, unitId: gramId }] },
},
});
const recipeB = await prisma.recipe.create({
data: {
name: "Tarte à l'oignon",
authorId,
portions: 4,
ingredients: { create: [{ ingredientId: onionId, quantity: 150, unitId: gramId }] },
},
});
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: [
{
planningId: planning.id,
weekDay: "lundi",
meal: "dejeuner",
recipeId: recipeA.id,
portions: 4,
},
{
planningId: planning.id,
weekDay: "mardi",
meal: "diner",
recipeId: recipeB.id,
portions: 4,
},
],
});
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.have.length(1);
expect(res.body.items[0].ingredient.key).to.equal("onion");
expect(res.body.items[0].quantity).to.equal(350);
});
it("keeps the same ingredient in two different units as two separate lines", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const tomatoId = await ingredientId("tomato");
const gramId = await unitId("gram");
const kilogramId = await unitId("kilogram");
const recipeA = await prisma.recipe.create({
data: {
name: "Recette A",
authorId,
portions: 2,
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
},
});
const recipeB = await prisma.recipe.create({
data: {
name: "Recette B",
authorId,
portions: 2,
ingredients: { create: [{ ingredientId: tomatoId, quantity: 1, unitId: kilogramId }] },
},
});
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: [
{
planningId: planning.id,
weekDay: "lundi",
meal: "dejeuner",
recipeId: recipeA.id,
portions: 2,
},
{
planningId: planning.id,
weekDay: "mardi",
meal: "diner",
recipeId: recipeB.id,
portions: 2,
},
],
});
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.have.length(2);
const units = res.body.items.map((item: { unit: { key: string } }) => item.unit.key).sort();
expect(units).to.deep.equal(["gram", "kilogram"]);
});
it("returns a different week's shopping list when asked for a date outside the current one", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const tomatoId = await ingredientId("tomato");
const gramId = await unitId("gram");
const recipe = await prisma.recipe.create({
data: {
name: "Curry de lentilles",
authorId,
portions: 2,
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
},
});
const nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 });
const planning = await prisma.planning.create({
data: {
houseId,
startDate: nextWeek.startOf("week").toJSDate(),
finishDate: nextWeek.endOf("week").startOf("day").toJSDate(),
},
});
await prisma.planningItem.create({
data: {
planningId: planning.id,
weekDay: "mardi",
meal: "dejeuner",
recipeId: recipe.id,
portions: 2,
},
});
const nextWeekRes = await agent.get("/shopping-list").query({ date: isoDate(nextWeek) });
expect(nextWeekRes.body.items).to.have.length(1);
const thisWeekRes = await agent.get("/shopping-list").query({ date: today() });
expect(thisWeekRes.body.items).to.deep.equal([]);
});
});
});

View file

@ -1,4 +1,3 @@
import { useState } from "react";
import "../../src/i18n/i18n";
import { TechStepCorrectionPopover } from "../../src/features/recipes/steps/TechStepCorrectionPopover";
@ -12,40 +11,15 @@ import { TechStepCorrectionPopover } from "../../src/features/recipes/steps/Tech
const cook = { id: 1, key: "cook" };
const simmer = { id: 3, key: "simmer" };
const butter = { id: 10, key: "butter" };
const pan = { id: 20, key: "pan" };
const gram = { id: 30, key: "gram" };
/**
* A real `StepDescription` resolves `onRequestSpan` into a fresh
* `resolvedMetadataSpan` via an actual browser text selection out of
* scope for a component test of the popover alone (covered by the e2e
* scenario instead). This harness fakes that round-trip with a fixed
* span, so tests here can exercise everything the popover itself is
* responsible for once a span comes back, without needing a real
* `StepDescription` in the tree.
*/
function Harness({
previousTechStepId = null,
existingIngredients = [],
existingUtensils = [],
onClose = () => {},
onSubmitted = () => {},
}: Partial<{
function mountPopover(
overrides: Partial<{
previousTechStepId: number | null;
existingIngredients: unknown[];
existingUtensils: unknown[];
onClose: () => void;
onSubmitted: (result: unknown) => void;
}>) {
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
nonce: number;
kind: "ingredient" | "utensil";
range: { start: number; end: number };
text: string;
} | null>(null);
return (
onSubmitted: (correction: unknown) => void;
}> = {},
) {
cy.mount(
<div>
{/* A genuinely separate sibling to click for the "outside click closes it" test — clicking blindly at a viewport coordinate would risk still landing inside the popover, which fills most of the mounted area on its own. */}
<div data-testid="outside-popover" style={{ height: 20 }} />
@ -54,25 +28,11 @@ function Harness({
stepId={2}
selectedText="Cuire"
range={{ start: 0, end: 5 }}
previousTechStepId={previousTechStepId}
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView props — precise typing isn't the point here.
existingIngredients={existingIngredients as any}
// biome-ignore lint/suspicious/noExplicitAny: see above.
existingUtensils={existingUtensils as any}
resolvedMetadataSpan={resolvedMetadataSpan}
onRequestSpan={(kind) =>
setResolvedMetadataSpan({
nonce: Date.now(),
kind,
range: { start: 20, end: 26 },
text: "Beurre",
})
}
onClose={onClose}
// biome-ignore lint/suspicious/noExplicitAny: see above.
onSubmitted={onSubmitted as any}
previousTechStepId={overrides.previousTechStepId ?? null}
onClose={overrides.onClose ?? (() => {})}
onSubmitted={overrides.onSubmitted ?? (() => {})}
/>
</div>
</div>,
);
}
@ -81,75 +41,32 @@ describe("TechStepCorrectionPopover", () => {
cy.intercept("GET", "**/reference/tech-steps", { statusCode: 200, body: [cook, simmer] }).as(
"getTechSteps",
);
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [butter] }).as(
"getIngredients",
);
cy.intercept("GET", "**/reference/units", { statusCode: 200, body: [gram] }).as("getUnits");
cy.intercept("GET", "**/reference/utensils", { statusCode: 200, body: [pan] }).as(
"getUtensils",
);
});
it("shows the selected text, the technique catalog (searchable) and the metadata sections all together", () => {
cy.mount(<Harness />);
it("shows the selected text and every technique option once loaded", () => {
mountPopover();
cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
// Merged editor (see TechStepCorrectionPopover's own doc comment) — no
// separate "pick, then metadata reveals itself" step, both render at
// once, and the technique catalog goes through the same searchable
// `CatalogSearchPicker` as the ingredient/utensil sub-flows (a plain
// unfiltered list of the real ~74-entry catalog isn't browsable).
cy.get(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
).should("have.length", 2);
cy.contains("h4", "Ingrédients").should("be.visible");
cy.contains("h4", "Ustensiles").should("be.visible");
cy.contains("button", "Valider").should("be.visible");
cy.get(".tech-step-correction-popover__list button").should("have.length", 2);
});
it("offers a 'no technique here' option, and marks the current pick, only when correcting an existing match", () => {
cy.mount(<Harness previousTechStepId={null} />);
it("offers a 'no technique here' option only when correcting an existing match", () => {
mountPopover({ previousTechStepId: null });
cy.wait("@getTechSteps");
cy.get(".tech-step-correction-popover__remove").should("not.exist");
cy.contains(".tech-step-correction-popover__chosen-technique", "Aucune technique sélectionnée");
cy.mount(<Harness previousTechStepId={cook.id} />);
mountPopover({ previousTechStepId: cook.id });
cy.wait("@getTechSteps");
cy.get(".tech-step-correction-popover__remove").should("exist");
cy.contains(".tech-step-correction-popover__chosen-technique", "Cuire");
cy.contains(".catalog-search-picker__list button", "Cuire").should(
"have.class",
"catalog-search-picker__item--selected",
);
});
it("picking a technique from the catalog selects it without submitting immediately", () => {
cy.mount(<Harness />);
cy.wait("@getTechSteps");
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains(".tech-step-correction-popover__chosen-technique", "Mijoter");
cy.contains("button", "Valider").should("be.visible");
});
it("Valider stays disabled until a technique is actually picked", () => {
cy.mount(<Harness />);
cy.wait("@getTechSteps");
cy.contains("button", "Valider").should("be.disabled");
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains("button", "Valider").should("not.be.disabled");
});
it("submits the selected technique (no metadata touched) with ingredients/utensils omitted from the request", () => {
it("submits the selected technique and calls onSubmitted", () => {
// Asserting on the resolved `@submitCorrection` interception below,
// rather than inside this handler — a Chai assertion failing *inside*
// a `cy.intercept` callback surfaces as an opaque "onResponse cannot be
// called twice" Cypress internal error instead of a normal assertion
// failure, found while writing this exact test.
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201,
body: {
@ -162,14 +79,10 @@ describe("TechStepCorrectionPopover", () => {
},
}).as("submitCorrection");
const onSubmitted = cy.stub().as("onSubmitted");
cy.mount(<Harness onSubmitted={onSubmitted} />);
mountPopover({ onSubmitted });
cy.wait("@getTechSteps");
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains("button", "Valider").click();
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
start: 0,
@ -180,85 +93,16 @@ describe("TechStepCorrectionPopover", () => {
cy.get("@onSubmitted").should("have.been.calledOnce");
});
it("adds an ingredient with quantity/unit via the span-selection flow, included in the submitted request", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201,
body: {
id: 1,
start: 0,
end: 5,
previousTechStep: null,
correctedTechStep: simmer,
createdAt: new Date().toISOString(),
},
}).as("submitCorrection");
cy.mount(<Harness />);
cy.wait("@getTechSteps");
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains("button", "+ Ajouter un ingrédient").click();
cy.contains(".catalog-search-picker button", "Beurre").click();
cy.get('input[type="number"]').type("50");
cy.get("select").select(String(gram.id));
cy.contains("button", "Ajouter").click();
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").should("be.visible");
cy.contains("button", "Valider").click();
cy.wait("@submitCorrection")
.its("request.body")
.should("deep.equal", {
start: 0,
end: 5,
previousTechStepId: null,
correctedTechStepId: simmer.id,
ingredients: [
{ ingredientId: butter.id, quantity: 50, unitId: gram.id, start: 20, end: 26 },
],
utensils: [],
});
});
it("pre-seeds existing ingredients/utensils, removable via their own chip", () => {
cy.mount(
<Harness
previousTechStepId={cook.id}
existingIngredients={[
{ ingredient: butter, quantity: 50, unit: gram, start: 0, end: 6, source: "auto" },
]}
existingUtensils={[{ utensil: pan, start: 14, end: 23, source: "auto" }]}
/>,
);
// An existing match starts pre-selected on itself (see
// TechStepCorrectionPopover's own doc comment) — the metadata sections,
// pre-seeded from `existingIngredients`/`existingUtensils`, are visible
// immediately, no need to re-pick "Cuire" from a list first.
cy.wait("@getTechSteps");
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").find("button").click();
cy.contains(".tech-step-correction-popover__chip", "Beurre").should("not.exist");
});
it("shows an error message and stays open when the submission fails", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 404,
body: { code: 4051, message: "TechStep not found" },
}).as("submitCorrection");
const onClose = cy.stub().as("onClose");
cy.mount(<Harness onClose={onClose} />);
mountPopover({ onClose });
cy.wait("@getTechSteps");
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Cuire",
).click();
cy.contains("button", "Valider").click();
cy.contains(".tech-step-correction-popover__list button", "Cuire").click();
cy.wait("@submitCorrection");
cy.get(".field-error").should("be.visible");
@ -267,7 +111,7 @@ describe("TechStepCorrectionPopover", () => {
it("calls onClose on an outside click", () => {
const onClose = cy.stub().as("onClose");
cy.mount(<Harness onClose={onClose} />);
mountPopover({ onClose });
cy.wait("@getTechSteps");
cy.get('[data-testid="outside-popover"]').click();

View file

@ -147,13 +147,11 @@ describe("Page width — full-bleed pages vs. centered reading columns (#21 regr
// that cap was dropped so they fill the width like every other page.
cy.visit("/parametres/compte");
assertFillsContentWidth(".settings-page");
cy.intercept("GET", /\/shopping-list\?/, {
statusCode: 200,
body: { startDate: "2026-08-17", finishDate: "2026-08-23", items: [] },
});
it("centers the Liste de courses stub, with equal space on both sides", () => {
cy.visit("/liste-de-courses");
assertFillsContentWidth(".shopping-list-page");
assertCenteredColumn(".coming-soon-page", 640); // max-width: 40rem
});
/** Fills `.app-content`'s available (padding-excluded) width, within a couple px of scrollbar/rounding slack. */
@ -170,6 +168,22 @@ describe("Page width — full-bleed pages vs. centered reading columns (#21 regr
});
});
}
/** Capped at `maxWidthPx` (not stretched full-bleed) and horizontally centered — equal left/right gap within `.app-content`. */
function assertCenteredColumn(selector: string, maxWidthPx: number) {
cy.get(".app-content").then(($content) => {
const contentRect = $content[0].getBoundingClientRect();
cy.get(selector).should(($page) => {
const pageRect = $page[0].getBoundingClientRect();
expect(pageRect.width).to.be.closeTo(maxWidthPx, 2);
const leftGap = pageRect.left - contentRect.left;
const rightGap = contentRect.right - pageRect.right;
expect(leftGap).to.be.closeTo(rightGap, 2);
});
});
}
});
describe("Responsive breakpoint — sidebar becomes a horizontal top bar under 640px", () => {

View file

@ -33,7 +33,7 @@ describe("Sidebar navigation", () => {
// The Foyer/Compte/Préférences links — behind the sidebar's "Paramètres"
// toggle, not the main nav tested here — are covered by sidebar.cy.ts.
it("highlights the current section and navigates between pages", () => {
it("highlights the current section and navigates between stub pages", () => {
cy.contains("nav a", "Planning").should("have.class", "active");
cy.contains("nav a", "Recettes").click();

View file

@ -88,16 +88,7 @@ Given('correcting step 2\'s "Cuire" match will succeed', () => {
correctedTechStep: { id: 3, key: "simmer" },
createdAt: new Date().toISOString(),
},
techSteps: [
{
techStep: { id: 3, key: "simmer" },
start: 0,
end: 5,
source: "manual",
ingredients: [],
utensils: [],
},
],
techSteps: [{ techStep: { id: 3, key: "simmer" }, start: 0, end: 5, source: "manual" }],
},
}).as("correction");
});
@ -110,21 +101,8 @@ Then("I should see the technique correction options", () => {
cy.get(".tech-step-correction-popover").should("be.visible");
});
// Picking a technique only *selects* it — it takes a separate "Valider"
// click to actually submit (room was made for attaching ingredient/utensil
// metadata alongside it, see `TechStepCorrectionPopover.tsx`'s own doc
// comment on its merged editor) — folded into this one step since nothing
// in this scenario cares about that intermediate state on its own. The
// technique catalog is picked via the same searchable `CatalogSearchPicker`
// the ingredient/utensil sub-flows use, scoped to
// `__technique-section` since that same search-and-pick component is
// reused inside this popover for more than just techniques.
When("I choose {string} as the correct technique", (label: string) => {
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
label,
).click();
cy.contains(".tech-step-correction-popover__confirm-button", "Valider").click();
cy.contains(".tech-step-correction-popover__list button", label).click();
});
Then(

View file

@ -1,32 +0,0 @@
Feature: Shopping list
As a signed-in user
I want to see every ingredient needed for this week's planned recipes, already summed
So that I know what to buy without recomputing it myself
Background:
Given I am signed in as "Alice" "Martin"
And today is frozen at "2026-08-17T09:00:00.000Z"
Scenario: Nothing planned this week shows the empty message, not an error
Given the shopping list for "2026-08-17" is empty
When I visit "/liste-de-courses"
Then I should see "Aucun ingrédient à acheter pour cette semaine ajoutez des recettes à votre planning."
Scenario: Ingredients are grouped by aisle, in canonical order, each with its summed quantity
Given the shopping list for "2026-08-17" contains:
| ingredientKey | icon | category | quantity | unitKey |
| egg | EGG | dairyAndCheese | 6 | piece |
| tomato | VEGETABLE | freshProduce | 400 | gram |
When I visit "/liste-de-courses"
Then the shopping list group "Produits frais" should appear before "Crémerie & fromage"
And the shopping list should show "Tomate" at quantity "400 g"
And the shopping list should show "Oeuf" at quantity "6 unité"
Scenario: Navigating to another week fetches and shows that week's own list
Given the shopping list for "2026-08-17" is empty
And the shopping list for "2026-08-24" contains:
| ingredientKey | icon | category | quantity | unitKey |
| onion | VEGETABLE | freshProduce | 1 | kilogram |
When I visit "/liste-de-courses"
And I click the next week arrow
Then the shopping list should show "Oignon" at quantity "1 kg"

View file

@ -1,72 +0,0 @@
import { type DataTable, Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
/**
* One data-table row a fake `ShoppingListItemView` same minimal-fixture
* convention as `recipe-form.ts`'s ingredient fixtures (only the fields
* `ShoppingListPage` actually reads at runtime: the ingredient's `key`/
* `icon`/`category` for `IngredientTypeIcon`/`CategoryIcon`/translation, the
* unit's `key`; `id` only needs to be unique per row for the React list
* key). `index` seeds both ids so two rows never collide.
*/
function buildShoppingListItem(
row: { ingredientKey: string; icon: string; category: string; quantity: string; unitKey: string },
index: number,
) {
return {
ingredient: {
id: index,
key: row.ingredientKey,
icon: row.icon,
category: row.category,
subcategory: row.category,
reproducible: false,
allergens: [],
diets: [],
},
quantity: Number(row.quantity),
unit: { id: index, key: row.unitKey, type: "MASS", toBaseFactor: 1 },
};
}
Given("the shopping list for {string} is empty", (date: string) => {
cy.intercept("GET", `**/shopping-list?date=${date}`, {
statusCode: 200,
body: { startDate: date, finishDate: date, items: [] },
});
});
Given("the shopping list for {string} contains:", (date: string, dataTable: DataTable) => {
const items = dataTable.hashes().map((row, i) => buildShoppingListItem(row, i + 1));
cy.intercept("GET", `**/shopping-list?date=${date}`, {
statusCode: 200,
body: { startDate: date, finishDate: date, items },
});
});
// Same class as PlanningPage's own week navigator (`WeekNavigator`, now
// shared between the two pages) — `planning-page.cy.ts` already exercises
// the prev/next arrows directly by class, same approach here.
When("I click the next week arrow", () => {
cy.get(".week-nav__arrow").last().click();
});
Then(
"the shopping list group {string} should appear before {string}",
(first: string, second: string) => {
cy.get(".shopping-list__group-title").then(($titles) => {
const texts = [...$titles].map((el) => el.textContent?.trim() ?? "");
const firstIndex = texts.findIndex((text) => text.includes(first));
const secondIndex = texts.findIndex((text) => text.includes(second));
expect(firstIndex, `"${first}" should be a rendered group`).to.be.greaterThan(-1);
expect(secondIndex, `"${second}" should be a rendered group`).to.be.greaterThan(-1);
expect(firstIndex).to.be.lessThan(secondIndex);
});
},
);
Then(
"the shopping list should show {string} at quantity {string}",
(name: string, quantity: string) => {
cy.contains(".shopping-list__item", name).should("contain.text", quantity);
},
);

View file

@ -17,7 +17,6 @@ import {
type RecipeTab,
type RecipeView,
type SafeUserProfile,
type ShoppingListView,
type SignupInput,
type SourceView,
type StepTechStepCorrectionView,
@ -27,7 +26,6 @@ import {
type ThemePreference,
type UnitView,
type UpdateRecipeInput,
type UtensilView,
} from "@batch-cooking/shared";
/**
@ -165,18 +163,6 @@ export class ApiClient {
return this._request(`/planning/items/${id}`, { method: "DELETE" });
}
/**
* Fetches the current user's household's shopping list for the week
* covering `date` (`YYYY-MM-DD`, e.g. from `date-tools`'s
* `formatDateOnly`) every ingredient across that week's planned
* recipes, summed. Unlike {@link getPlanningForWeek}, never resolves to
* `null`: no household or nothing planned that week both come back as a
* normal list with an empty `items` array.
*/
public getShoppingListForWeek(date: string): Promise<ShoppingListView> {
return this._request(`/shopping-list?date=${date}`);
}
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
public getDiets(): Promise<DietView[]> {
return this._request("/reference/diets");
@ -202,11 +188,6 @@ export class ApiClient {
return this._request("/reference/tech-steps");
}
/** Reference list of cooking utensils — static, non-administrable (`TechStepCorrectionPopover`'s utensil picker, once a technique is selected). Public — no session required. */
public getUtensils(): Promise<UtensilView[]> {
return this._request("/reference/utensils");
}
/** Reference list of implemented recipe sources (onboarding wizard's source step, `/parametres/foyer`) — empty until a concrete source is registered. Public — no session required. */
public getSources(): Promise<SourceView[]> {
return this._request("/reference/sources");

View file

@ -0,0 +1,17 @@
// =============================================================================
// Styles for ComingSoonPage shared by every stub section page.
// =============================================================================
// Centered, not pinned to `.app-content`'s left edge — same reasoning as
// `.settings-page` (settings-pages.scss): on a wide desktop viewport a
// left-aligned `max-width` here just left a lopsided gap down the right
// side instead of framing the placeholder copy.
.coming-soon-page {
max-width: 40rem;
margin: 0 auto;
p {
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
}

View file

@ -0,0 +1,26 @@
import "./ComingSoonPage.scss";
interface ComingSoonPageProps {
title: string;
description: string;
}
/**
* Placeholder rendered by a section that has a route/sidebar entry but no
* real feature behind it yet today only `pages/shopping-list/ShoppingListPage.tsx`
* (`Recettes`/`Foyer & profil` both grew real backends since this was
* written, see `pages/recipes/`/`pages/settings/`). Kept as a shared,
* reusable component (`components/ui/`, not itself a routed page) rather
* than inlined into that one page, so a future stub section doesn't need to
* hand-roll the same markup the page that needs it still gets its own
* file (and its own copy, via i18n), just wrapping this instead of
* rewriting it.
*/
export function ComingSoonPage({ title, description }: ComingSoonPageProps) {
return (
<div className="coming-soon-page">
<h1>{title}</h1>
<p>{description}</p>
</div>
);
}

View file

@ -1,174 +0,0 @@
import {
addWeeks,
buildCalendarMonth,
DateTime,
getWeekStart,
toDateOnly,
} from "@batch-cooking/date-tools";
import { WEEK_DAYS } from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import "./week-navigator.scss";
/** "17 au 23 août 2026" — collapses the month/year to just the end date when both ends of the week share it, spells it out on both ends otherwise (e.g. a week straddling two months). */
function formatWeekRange(weekStart: DateTime): string {
const weekEnd = weekStart.plus({ days: 6 });
const sameMonth = weekStart.hasSame(weekEnd, "month");
const startLabel = weekStart.toLocaleString(
sameMonth ? { day: "numeric" } : { day: "numeric", month: "long" },
{ locale: "fr" },
);
const endLabel = weekEnd.toLocaleString(
{ day: "numeric", month: "long", year: "numeric" },
{ locale: "fr" },
);
return `${startLabel} au ${endLabel}`;
}
/**
* Arrows + clickable label opening {@link CalendarPopover} week-selection
* UI shared by any page organized around "one week at a time" (originally
* `PlanningPage`'s own grid, now also `ShoppingListPage` both just need a
* `weekStart` in/out, neither cares how the other renders its own content
* for that week). Copy comes from `common.weekNav.*`/`common.calendar.*`/
* `common.days.*` rather than `planning.*` generic enough ("Semaine
* précédente", day names) to not read as planning-specific from a page that
* isn't the planning grid.
*/
export function WeekNavigator({
weekStart,
onChangeWeek,
}: {
weekStart: DateTime;
onChangeWeek: (weekStart: DateTime) => void;
}) {
const { t } = useTranslation();
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const isThisWeek = weekStart.hasSame(getWeekStart(DateTime.utc()), "day");
return (
<div className="week-nav">
<button
type="button"
className="week-nav__arrow"
title={t("common.weekNav.prevWeek")}
onClick={() => onChangeWeek(addWeeks(weekStart, -1))}
>
</button>
<button
type="button"
className="week-nav__label"
onClick={() => setIsCalendarOpen((open) => !open)}
>
📅 {t("common.weekNav.label", { range: formatWeekRange(weekStart) })}
{isThisWeek && <span className="today-badge">{t("common.weekNav.thisWeek")}</span>}
</button>
<button
type="button"
className="week-nav__arrow"
title={t("common.weekNav.nextWeek")}
onClick={() => onChangeWeek(addWeeks(weekStart, 1))}
>
</button>
{isCalendarOpen && (
<CalendarPopover
selectedWeekStart={weekStart}
onSelectDay={(day) => {
onChangeWeek(getWeekStart(day));
setIsCalendarOpen(false);
}}
onClose={() => setIsCalendarOpen(false)}
/>
)}
</div>
);
}
/** Month calendar letting the visitor jump to any week at once — selecting a day selects its whole (Monday-first) week. Closes itself on an outside click. */
function CalendarPopover({
selectedWeekStart,
onSelectDay,
onClose,
}: {
selectedWeekStart: DateTime;
onSelectDay: (day: DateTime) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
// Its own state: browsing to a different month to pick a week there
// shouldn't jump back every render — only re-anchors when the popover is
// first opened (`selectedWeekStart` at that point), not while it's open.
const [visibleMonth, setVisibleMonth] = useState(() => selectedWeekStart.startOf("month"));
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const today = toDateOnly(DateTime.utc());
const selectedWeekEnd = selectedWeekStart.plus({ days: 6 });
const weeks = buildCalendarMonth(visibleMonth);
return (
<div className="calendar-popover" ref={popoverRef}>
<div className="calendar-popover__header">
<button
type="button"
title={t("common.calendar.prevMonth")}
onClick={() => setVisibleMonth((month) => month.minus({ months: 1 }))}
>
</button>
<span>
{visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })}
</span>
<button
type="button"
title={t("common.calendar.nextMonth")}
onClick={() => setVisibleMonth((month) => month.plus({ months: 1 }))}
>
</button>
</div>
<div className="calendar-grid">
{WEEK_DAYS.map((weekDay) => (
<span key={weekDay} className="calendar-grid__weekday">
{t(`common.days.${weekDay}`).charAt(0)}
</span>
))}
{weeks.flat().map((day) => {
const classNames = ["calendar-grid__day"];
if (!day.hasSame(visibleMonth, "month")) classNames.push("calendar-grid__day--muted");
if (day >= selectedWeekStart && day <= selectedWeekEnd) {
classNames.push("calendar-grid__day--in-selected-week");
}
if (day.hasSame(today, "day")) classNames.push("calendar-grid__day--today");
return (
<button
key={day.toISO()}
type="button"
className={classNames.join(" ")}
onClick={() => onSelectDay(day)}
>
{day.day}
</button>
);
})}
</div>
</div>
);
}

View file

@ -1,143 +0,0 @@
// =============================================================================
// Styles for WeekNavigator.tsx (arrows + label + calendar popover)
// colocated next to the component since nothing else uses these classes.
// Extracted from planning-page.scss once ShoppingListPage started reusing
// the component same design tokens, no light/dark duplication needed
// (every `var(--color-*)` below already resolves per-theme globally, see
// styles/_theme.scss).
// =============================================================================
.week-nav {
position: relative;
display: flex;
align-items: center;
gap: var(--space-xs);
&__arrow {
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--font-size-md);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
}
&__label {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 0.45rem var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font-weight: 600;
font-size: var(--font-size-sm);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
}
}
.today-badge {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
padding: 0.1rem 0.4rem;
border-radius: var(--radius-pill);
}
// --- Calendar popover -------------------------------------------------------
.calendar-popover {
position: absolute;
top: calc(100% + var(--space-xs));
right: 0;
z-index: 10;
width: 18rem;
padding: var(--space-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-sm);
font-weight: 700;
font-size: var(--font-size-sm);
text-transform: capitalize;
button {
width: 1.6rem;
height: 1.6rem;
border: none;
background: none;
cursor: pointer;
font-size: var(--font-size-base);
color: var(--color-text-muted);
border-radius: var(--radius-base);
&:hover {
background: var(--color-surface-alt);
}
}
}
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
&__weekday {
text-align: center;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-weight: 600;
padding-bottom: var(--space-xs);
}
&__day {
aspect-ratio: 1;
display: grid;
place-items: center;
font-size: var(--font-size-sm);
border-radius: var(--radius-base);
cursor: pointer;
color: var(--color-text);
border: none;
background: none;
font: inherit;
&:hover {
background: var(--color-surface-alt);
}
&--muted {
color: var(--color-border);
}
&--in-selected-week {
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border-radius: 0;
}
&--today {
box-shadow: inset 0 0 0 2px var(--color-primary);
font-weight: 700;
}
}
}

View file

@ -729,41 +729,43 @@
margin: 0 0 var(--space-sm);
}
// Technique picker + Ingrédients/Ustensiles render together as one
// screen now (see `TechStepCorrectionPopover.tsx`'s own doc comment) —
// this section just needs its own small header row, the actual picker
// is `.catalog-search-picker` (below), reused as-is from the ingredient/
// utensil sub-flows.
&__technique-section {
h4 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
&__technique-header {
&__list {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-sm);
}
flex-wrap: wrap;
gap: var(--space-xs);
list-style: none;
margin: 0 0 var(--space-sm);
padding: 0;
&__remove {
padding: 0.2rem 0.5rem;
button {
padding: 0.3rem 0.6rem;
font-size: var(--font-size-sm);
color: var(--color-error);
background: none;
border: 1px solid var(--color-error);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
cursor: pointer;
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border-color: var(--color-primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// Nested (rather than a sibling `&__remove` block) so its border-color
// wins over the plain `button` rule above by class-count specificity,
// no `!important` needed.
.tech-step-correction-popover__remove {
color: var(--color-error);
border-color: var(--color-error);
}
}
&__cancel {
background: none;
border: none;
@ -773,180 +775,6 @@
padding: 0;
font-size: var(--font-size-sm);
}
// Shown in place of the technique list/metadata sections while
// `StepDescription` is waiting on a second text selection (see
// `TechStepCorrectionPopover.tsx`'s own doc comment) — same styling
// intent as `.recipe-detail-panel__tech-step-hint`, a small muted aside.
&__hint {
margin: 0 0 var(--space-sm);
color: var(--color-text-muted);
font-style: italic;
}
&__span-picker {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
&__quantity-line {
display: flex;
align-items: center;
gap: var(--space-xs);
input[type="number"] {
width: 5rem;
}
}
&__confirm {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
// "Aucune technique sélectionnée."/"Technique retenue : X" no button
// here anymore (re-picking happens directly through the search picker
// right below, see `TechStepCorrectionPopover.tsx`'s doc comment on the
// merged editor), just a small status line.
&__chosen-technique {
margin: 0 0 var(--space-xs);
font-weight: 600;
}
&__metadata-section {
margin-top: var(--space-sm);
h4 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// "+ Ajouter…" button deliberately a plain text-link style, not
// another pill button (`.catalog-search-picker__list button`) this
// is a secondary action inside an already-open popover, not a
// top-level choice competing with the chips above it.
> button {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
padding: 0;
font-size: var(--font-size-sm);
}
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
list-style: none;
margin: 0 0 var(--space-xs);
padding: 0;
}
&__chip {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 0.3rem 0.6rem;
font-size: var(--font-size-sm);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
button {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 0;
line-height: 1;
&:hover:not(:disabled) {
color: var(--color-error);
}
}
}
&__confirm-button {
align-self: flex-start;
padding: 0.4rem 1rem;
font-size: var(--font-size-sm);
color: var(--color-surface);
background: var(--color-primary);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
// Reused by both the ingredient and utensil "attach to this correction"
// sub-flows (`TechStepCorrectionPopover.tsx`) deliberately lighter than
// `.ingredient-picker` (no category/subcategory grid, no allergen/diet
// toggles), sized for a small popover rather than a full recipe form.
.catalog-search-picker {
display: flex;
flex-direction: column;
gap: var(--space-xs);
&__input {
width: 100%;
}
&__empty {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
&__list {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
list-style: none;
margin: 0;
padding: 0;
// Raised from the original 8rem this component is now also the
// technique picker (~74 entries, see this file's own doc comment),
// where 8rem left only a couple of rows visible before scrolling.
max-height: 14rem;
overflow-y: auto;
button {
padding: 0.3rem 0.6rem;
font-size: var(--font-size-sm);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
cursor: pointer;
&:hover {
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border-color: var(--color-primary);
}
// The technique picker's current pick (`selectedId` prop) — stays
// visually marked even while filtered/scrolled past, so re-opening
// this popover's picker doesn't read as "nothing chosen yet" when
// something already is. Unused by the ingredient/utensil sub-flows
// (they never pass `selectedId` each pick there just appends a
// fresh mention, nothing to mark as "current").
&.catalog-search-picker__item--selected {
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
border-color: var(--color-primary);
font-weight: 600;
}
}
}
}
// --- Favorite star toggle (detail panel header) -----------------------------

View file

@ -1,71 +0,0 @@
import { useState } from "react";
/**
* Small search-and-pick list a lighter alternative to `IngredientPicker.tsx`
* (category/subcategory grid + allergen/diet toggles) for a context that
* doesn't have room for that: `TechStepCorrectionPopover.tsx`'s ingredient/
* utensil/**technique** pickers, all embedded in a small popover rather than
* a full recipe form. Reused for all three an ingredient, a utensil, and a
* technique are all "search a reference list by translated label, pick one"
* from this component's point of view, the only difference is which
* `items`/labels the caller passes in. The technique catalog in particular
* (~74 entries) is exactly the case a plain unfiltered list stops being
* readable at the original motivation for adding search here at all.
*
* Deliberately just `{ id, label }` in, `id` out no `IngredientView`/
* `UtensilView`/`TechStepView` dependency here, so this stays reusable for
* any future "search this small reference catalog" need without growing a
* new prop per catalog shape.
*/
export function CatalogSearchPicker({
items,
selectedId,
onSelect,
placeholder,
emptyLabel,
}: {
items: { id: number; label: string }[];
/** The currently-picked item, if any — marked with a distinct modifier class so it stays visible at a glance while browsing/filtering a longer list (e.g. `TechStepCorrectionPopover`'s ~74-entry technique catalog), not just implied by whatever's selected elsewhere on screen. Omit for a picker with no notion of a "current" pick (the ingredient/utensil span sub-flows — each `onSelect` there just appends a brand-new mention, nothing to mark as already chosen). */
selectedId?: number;
onSelect: (id: number) => void;
placeholder: string;
emptyLabel: string;
}) {
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const visible =
normalizedQuery.length === 0
? items
: items.filter((item) => item.label.toLowerCase().includes(normalizedQuery));
return (
<div className="catalog-search-picker">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="catalog-search-picker__input"
/>
{visible.length === 0 ? (
<p className="catalog-search-picker__empty">{emptyLabel}</p>
) : (
<ul className="catalog-search-picker__list">
{visible.map((item) => (
<li key={item.id}>
<button
type="button"
className={
item.id === selectedId ? "catalog-search-picker__item--selected" : undefined
}
onClick={() => onSelect(item.id)}
>
{item.label}
</button>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -76,45 +76,10 @@ export function StepDescription({
previousTechStepId: number | null;
} | null>(null);
// Routes the *next* text selection to the open `TechStepCorrectionPopover`
// (as an ingredient/utensil mention span) instead of opening a brand-new
// correction — set when that popover calls `onRequestSpan`, cleared once
// `handleMouseUp` resolves the selection below. See
// `TechStepCorrectionPopover.tsx`'s own doc comment for why this can live
// entirely alongside the still-visible, still-selectable description
// rather than needing the popover itself to move/hide.
const [pendingSpanRequest, setPendingSpanRequest] = useState<"ingredient" | "utensil" | null>(
null,
);
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
nonce: number;
kind: "ingredient" | "utensil";
range: TextSelectionRange;
text: string;
} | null>(null);
const nextMetadataSpanNonce = useRef(0);
function closeActiveCorrection() {
setActiveCorrection(null);
setPendingSpanRequest(null);
setResolvedMetadataSpan(null);
}
function handleMouseUp() {
if (!editable) return;
const range = getSelectionRange();
if (!range) return;
if (pendingSpanRequest !== null) {
nextMetadataSpanNonce.current += 1;
setResolvedMetadataSpan({
nonce: nextMetadataSpanNonce.current,
kind: pendingSpanRequest,
range,
text: description.slice(range.start, range.end),
});
setPendingSpanRequest(null);
return;
}
setActiveCorrection({
range,
selectedText: description.slice(range.start, range.end),
@ -126,21 +91,6 @@ export function StepDescription({
setLiveTechSteps(result.techSteps);
}
// The occurrence `activeCorrection` is currently open for, matched by its
// exact `[start, end)` (not just `techStep.id` — the same technique can
// legitimately occur more than once in one description) — whatever
// ingredients/utensils it already carries seed
// `TechStepCorrectionPopover`'s own pending lists. `undefined` (not an
// empty array) for a brand-new selection, same as "nothing to look up
// yet".
const activeStepTechStep = activeCorrection
? liveTechSteps.find(
(techStep) =>
techStep.start === activeCorrection.range.start &&
techStep.end === activeCorrection.range.end,
)
: undefined;
// Tracks each segment's own absolute start offset into `description` as
// the map below walks them in order — segments are contiguous and cover
// the whole description (see `splitDescriptionByTechSteps`'s doc
@ -204,19 +154,12 @@ export function StepDescription({
data-offset={editable ? start : undefined}
onClick={
editable
? () => {
// Clears any in-progress ingredient/utensil
// span-selection from whatever correction was open
// before — opening a *different* one has nothing
// left to resolve that selection into.
setPendingSpanRequest(null);
setResolvedMetadataSpan(null);
? () =>
setActiveCorrection({
range: { start, end },
selectedText: segment.text,
previousTechStepId: techStep.id,
});
}
})
: undefined
}
>
@ -233,11 +176,7 @@ export function StepDescription({
range={activeCorrection.range}
selectedText={activeCorrection.selectedText}
previousTechStepId={activeCorrection.previousTechStepId}
existingIngredients={activeStepTechStep?.ingredients ?? []}
existingUtensils={activeStepTechStep?.utensils ?? []}
resolvedMetadataSpan={resolvedMetadataSpan}
onRequestSpan={setPendingSpanRequest}
onClose={closeActiveCorrection}
onClose={() => setActiveCorrection(null)}
onSubmitted={handleSubmitted}
/>
)}

View file

@ -1,48 +1,14 @@
import {
ErrorCode,
type IngredientView,
type StepTechStepIngredientView,
type StepTechStepUtensilView,
type SubmitTechStepCorrectionResult,
type TechStepView,
type UnitView,
type UtensilView,
} from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../../api/client";
import { errorMessageService } from "../../../services/error-message.service";
import { CatalogSearchPicker } from "./CatalogSearchPicker";
import type { TextSelectionRange } from "./use-text-selection";
/** One ingredient the viewer has attached (or is about to submit) — the trimmed-down shape `POST .../corrections`'s `ingredients[]` expects, kept separately from `StepTechStepIngredientView` since a pending one has no resolved `IngredientView`/`UnitView` to carry yet, only ids. */
interface PendingIngredient {
ingredientId: number;
quantity: number | null;
unitId: number | null;
start: number;
end: number;
}
/** Same as {@link PendingIngredient}, for a utensil (no quantity/unit — nothing to measure). */
interface PendingUtensil {
utensilId: number;
start: number;
end: number;
}
function toPendingIngredient(view: StepTechStepIngredientView): PendingIngredient {
return {
ingredientId: view.ingredient.id,
quantity: view.quantity,
unitId: view.unit?.id ?? null,
start: view.start,
end: view.end,
};
}
function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
return { utensilId: view.utensil.id, start: view.start, end: view.end };
}
/**
* Small non-modal popover letting a viewer assign a technique to a selected
* span of a step's description, or clear/relabel an existing match
@ -55,29 +21,14 @@ function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
* `StepDescription.tsx`), not floating anchored at the selection's exact
* position simpler and more robust than tracking a caret-anchored
* position across scroll/resize, at the cost of a little visual distance
* from the selected text itself. That placement matters beyond cosmetics
* here: it's *why* the "attach an ingredient/utensil" flow below can ask
* the viewer to select a second span of text without closing this popover
* first the description stays fully visible and selectable the whole
* time, nothing overlays it.
* from the selected text itself.
*
* **One merged editor, not a wizard**: picking a technique
* (`CatalogSearchPicker`, searchable the reference catalog is ~74
* entries, an unfiltered flat list wasn't browsable) and editing its
* Ingrédients/Ustensiles metadata render together on the same screen,
* always there's no separate "pick, then a metadata step reveals
* itself" sequence to go through, and no dead end where metadata is
* technically attachable but not visible until some other action happens
* first. A single "Valider" submits everything at once; disabled until a
* technique is actually selected (there's nothing to attach metadata to
* otherwise). **Removing** a match (`submit(null)`) stays its own
* immediate action next to the picker nothing to attach when removing.
*
* The two metadata sections are pre-seeded from `existingIngredients`/
* `existingUtensils` (whatever's already attached to this occurrence, auto-
* or manually-sourced `[]` for a brand-new technique) and editable via
* add/remove see `metadataTouched` below for why what's *displayed* here
* isn't automatically what gets *submitted*.
* Submitting takes effect immediately the API applies it to the step's
* real `StepTechStep` sequence as it records the correction (a `"manual"`-
* tagged entry, see `StepTechStepCorrection`'s schema doc comment) and
* returns the fresh sequence, which `onSubmitted` hands back to
* `StepDescription` to render right away, styled differently from an
* `"auto"` match.
*/
export function TechStepCorrectionPopover({
recipeId,
@ -85,10 +36,6 @@ export function TechStepCorrectionPopover({
selectedText,
range,
previousTechStepId,
existingIngredients,
existingUtensils,
resolvedMetadataSpan,
onRequestSpan,
onClose,
onSubmitted,
}: {
@ -99,74 +46,12 @@ export function TechStepCorrectionPopover({
range: TextSelectionRange;
/** Set when correcting an already-detected match (opened from clicking its highlight) rather than a fresh selection — passed through as-is on submit, and offers a "remove" option `null` doesn't. */
previousTechStepId: number | null;
/** Whatever ingredients/utensils already sit on this occurrence (both `"auto"` and `"manual"` sourced) — `[]` for a brand-new technique, nothing to pre-seed. */
existingIngredients: StepTechStepIngredientView[];
existingUtensils: StepTechStepUtensilView[];
/**
* A text span `StepDescription` just resolved on this popover's behalf,
* after a call to `onRequestSpan` below `null` until then. Identified
* by `nonce` (not by value) so this popover's own `useEffect` reliably
* fires once per fresh selection, even if the exact same span is
* selected twice in a row.
*/
resolvedMetadataSpan: {
nonce: number;
kind: "ingredient" | "utensil";
range: TextSelectionRange;
text: string;
} | null;
/** Tells `StepDescription` "the next text selection in the description is for an ingredient/utensil mention, not a new technique correction" — see this component's own doc comment. */
onRequestSpan: (kind: "ingredient" | "utensil") => void;
onClose: () => void;
onSubmitted: (result: SubmitTechStepCorrectionResult) => void;
}) {
const { t } = useTranslation();
const popoverRef = useRef<HTMLDivElement>(null);
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
// Opening this popover on an *already-detected* match (`previousTechStepId
// !== null`, i.e. the user clicked an existing highlight rather than
// selecting fresh text) starts pre-selected on that same technique, its
// name shown next to the picker right away — since the picker and the
// metadata sections render together regardless (see this component's own
// doc comment), this just saves re-picking the technique that's already
// correct before its metadata becomes editable.
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
const [catalogs, setCatalogs] = useState<{
ingredients: IngredientView[];
units: UnitView[];
utensils: UtensilView[];
} | null>(null);
const [pendingIngredients, setPendingIngredients] = useState<PendingIngredient[]>(() =>
existingIngredients.map(toPendingIngredient),
);
const [pendingUtensils, setPendingUtensils] = useState<PendingUtensil[]>(() =>
existingUtensils.map(toPendingUtensil),
);
// Flips true the moment the viewer adds/removes a pending entry — never
// from the initial seeding above. `submit()` below only includes
// `ingredients`/`utensils` in the request when this is true, so
// relabeling/confirming a technique without ever opening either section
// leaves existing metadata completely alone server-side (see
// `submitTechStepCorrectionSchema`'s own doc comment, `packages/shared`,
// for why an *omitted* field — not an empty array — is what "don't
// touch it" means over the wire).
const [metadataTouched, setMetadataTouched] = useState(false);
const [awaitingSpanFor, setAwaitingSpanFor] = useState<"ingredient" | "utensil" | null>(null);
const [activeSpan, setActiveSpan] = useState<{
kind: "ingredient" | "utensil";
range: TextSelectionRange;
text: string;
} | null>(null);
// Only meaningful while `activeSpan?.kind === "ingredient"` — the
// ingredient sub-flow is itself two steps (pick the ingredient, then its
// quantity/unit), this is where the first step's choice waits until the
// second is confirmed.
const [pickedIngredientId, setPickedIngredientId] = useState<number | null>(null);
const [spanQuantity, setSpanQuantity] = useState("");
const [spanUnitId, setSpanUnitId] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -185,46 +70,6 @@ export function TechStepCorrectionPopover({
};
}, []);
// Fetched unconditionally on mount — the Ingrédients/Ustensiles sections
// render alongside the technique picker from the start (see this
// component's own doc comment on the merged editor), so there's no later
// point to defer this to anymore.
useEffect(() => {
if (catalogs !== null) return;
let cancelled = false;
Promise.all([apiClient.getIngredients(), apiClient.getUnits(), apiClient.getUtensils()])
.then(([ingredients, units, utensils]) => {
if (!cancelled) setCatalogs({ ingredients, units, utensils });
})
.catch(() => {
if (!cancelled) setCatalogs({ ingredients: [], units: [], utensils: [] });
});
return () => {
cancelled = true;
};
}, [catalogs]);
// Consumes a span `StepDescription` just resolved on this popover's
// behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the
// matching sub-picker and clears the "awaiting a selection" hint.
useEffect(() => {
if (resolvedMetadataSpan === null) return;
setActiveSpan({
kind: resolvedMetadataSpan.kind,
range: resolvedMetadataSpan.range,
text: resolvedMetadataSpan.text,
});
setAwaitingSpanFor(null);
setPickedIngredientId(null);
setSpanQuantity("");
setSpanUnitId(null);
// Depends on the whole object, not just `.nonce` — `StepDescription`
// only ever calls its setter with a brand-new object (never mutates
// one in place), so reference equality alone already gives this the
// "fires once per fresh selection" behavior `nonce` documents, with no
// need to silence the exhaustive-deps lint to get there.
}, [resolvedMetadataSpan]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
@ -235,7 +80,7 @@ export function TechStepCorrectionPopover({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
async function removeMatch() {
async function submit(correctedTechStepId: number | null) {
setIsSubmitting(true);
setError(null);
try {
@ -243,7 +88,7 @@ export function TechStepCorrectionPopover({
start: range.start,
end: range.end,
previousTechStepId,
correctedTechStepId: null,
correctedTechStepId,
});
onSubmitted(result);
onClose();
@ -254,260 +99,40 @@ export function TechStepCorrectionPopover({
}
}
async function confirm() {
if (selectedTechStepId === null) return;
setIsSubmitting(true);
setError(null);
try {
const result = await apiClient.submitTechStepCorrection(recipeId, stepId, {
start: range.start,
end: range.end,
previousTechStepId,
correctedTechStepId: selectedTechStepId,
...(metadataTouched ? { ingredients: pendingIngredients, utensils: pendingUtensils } : {}),
});
onSubmitted(result);
onClose();
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setError(errorMessageService.getLabel(code));
setIsSubmitting(false);
}
}
function requestSpan(kind: "ingredient" | "utensil") {
setAwaitingSpanFor(kind);
onRequestSpan(kind);
}
function cancelSpanSelection() {
setAwaitingSpanFor(null);
setActiveSpan(null);
setPickedIngredientId(null);
}
function confirmIngredientSpan() {
if (activeSpan === null || pickedIngredientId === null) return;
const trimmed = spanQuantity.trim();
const parsedQuantity = trimmed.length > 0 ? Number(trimmed) : null;
setPendingIngredients((prev) => [
...prev,
{
ingredientId: pickedIngredientId,
quantity:
parsedQuantity !== null && Number.isFinite(parsedQuantity) ? parsedQuantity : null,
unitId: spanUnitId,
start: activeSpan.range.start,
end: activeSpan.range.end,
},
]);
setMetadataTouched(true);
setActiveSpan(null);
setPickedIngredientId(null);
}
function confirmUtensilSpan(utensilId: number) {
if (activeSpan === null) return;
setPendingUtensils((prev) => [
...prev,
{ utensilId, start: activeSpan.range.start, end: activeSpan.range.end },
]);
setMetadataTouched(true);
setActiveSpan(null);
}
function removeIngredient(index: number) {
setPendingIngredients((prev) => prev.filter((_, i) => i !== index));
setMetadataTouched(true);
}
function removeUtensil(index: number) {
setPendingUtensils((prev) => prev.filter((_, i) => i !== index));
setMetadataTouched(true);
}
const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i]));
const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u]));
const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u]));
return (
<div className="tech-step-correction-popover" ref={popoverRef}>
<p className="tech-step-correction-popover__selection">
{t("recipes.techStepCorrection.selectionLabel", { text: selectedText })}
</p>
{awaitingSpanFor !== null ? (
<p className="tech-step-correction-popover__hint">
{t("recipes.techStepCorrection.selectSpanHint")}
</p>
) : activeSpan !== null ? (
<div className="tech-step-correction-popover__span-picker">
<p className="tech-step-correction-popover__selection">
{t("recipes.techStepCorrection.selectionLabel", { text: activeSpan.text })}
</p>
{activeSpan.kind === "ingredient" ? (
pickedIngredientId === null ? (
<CatalogSearchPicker
items={(catalogs?.ingredients ?? []).map((ingredient) => ({
id: ingredient.id,
label: t(`catalog.ingredients.${ingredient.key}`),
}))}
onSelect={setPickedIngredientId}
placeholder={t("recipes.form.searchIngredientPlaceholder")}
emptyLabel={t("recipes.form.noIngredientFound")}
/>
) : (
<div className="tech-step-correction-popover__quantity-line">
<input
type="number"
min="0"
step="any"
value={spanQuantity}
onChange={(e) => setSpanQuantity(e.target.value)}
aria-label={t("recipes.form.quantityLabel")}
/>
<select
value={spanUnitId ?? ""}
onChange={(e) => setSpanUnitId(e.target.value ? Number(e.target.value) : null)}
aria-label={t("recipes.form.unitLabel")}
>
<option value="">{t("recipes.form.unitPlaceholder")}</option>
{(catalogs?.units ?? []).map((unit) => (
<option key={unit.id} value={unit.id}>
{t(`catalog.units.${unit.key}`)}
</option>
))}
</select>
<button type="button" onClick={confirmIngredientSpan}>
{t("recipes.techStepCorrection.addToList")}
</button>
</div>
)
) : (
<CatalogSearchPicker
items={(catalogs?.utensils ?? []).map((utensil) => ({
id: utensil.id,
label: t(`catalog.utensils.${utensil.key}`),
}))}
onSelect={confirmUtensilSpan}
placeholder={t("recipes.techStepCorrection.searchUtensilPlaceholder")}
emptyLabel={t("recipes.techStepCorrection.noUtensilFound")}
/>
)}
<button
type="button"
className="tech-step-correction-popover__cancel"
onClick={cancelSpanSelection}
>
{t("recipes.techStepCorrection.cancelSpanSelection")}
</button>
</div>
) : techSteps === null ? (
{techSteps === null ? (
<p>{t("recipes.loading")}</p>
) : (
<div className="tech-step-correction-popover__confirm">
<section className="tech-step-correction-popover__technique-section">
<div className="tech-step-correction-popover__technique-header">
<h4>{t("recipes.techStepCorrection.techniqueSection")}</h4>
<ul className="tech-step-correction-popover__list">
{previousTechStepId !== null && (
<li>
<button
type="button"
disabled={isSubmitting}
onClick={removeMatch}
onClick={() => submit(null)}
className="tech-step-correction-popover__remove"
>
{t("recipes.techStepCorrection.removeMatch")}
</button>
</li>
)}
</div>
<p className="tech-step-correction-popover__chosen-technique">
{selectedTechStepId !== null
? t("recipes.techStepCorrection.currentTechnique", {
technique: t(
`catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`,
),
})
: t("recipes.techStepCorrection.noTechniqueSelected")}
</p>
<CatalogSearchPicker
items={techSteps.map((techStep) => ({
id: techStep.id,
label: t(`catalog.techSteps.${techStep.key}`),
}))}
selectedId={selectedTechStepId ?? undefined}
onSelect={setSelectedTechStepId}
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
/>
</section>
<section className="tech-step-correction-popover__metadata-section">
<h4>{t("recipes.techStepCorrection.ingredientsSection")}</h4>
<ul className="tech-step-correction-popover__chips">
{pendingIngredients.map((ingredient, index) => {
const view = ingredientById.get(ingredient.ingredientId);
const unit =
ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
const label = view ? t(`catalog.ingredients.${view.key}`) : "…";
return (
// biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
<li key={index} className="tech-step-correction-popover__chip">
{ingredient.quantity !== null ? `${ingredient.quantity} ` : ""}
{unit ? `${t(`catalog.units.${unit.key}`)} ` : ""}
{label}
{techSteps.map((techStep) => (
<li key={techStep.id}>
<button
type="button"
onClick={() => removeIngredient(index)}
title={t("recipes.techStepCorrection.removeIngredient")}
disabled={isSubmitting}
disabled={isSubmitting || techStep.id === previousTechStepId}
onClick={() => submit(techStep.id)}
>
{t(`catalog.techSteps.${techStep.key}`)}
</button>
</li>
);
})}
))}
</ul>
<button type="button" onClick={() => requestSpan("ingredient")} disabled={isSubmitting}>
{t("recipes.techStepCorrection.addIngredient")}
</button>
</section>
<section className="tech-step-correction-popover__metadata-section">
<h4>{t("recipes.techStepCorrection.utensilsSection")}</h4>
<ul className="tech-step-correction-popover__chips">
{pendingUtensils.map((utensil, index) => {
const view = utensilById.get(utensil.utensilId);
return (
// biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient chip list above.
<li key={index} className="tech-step-correction-popover__chip">
{view ? t(`catalog.utensils.${view.key}`) : "…"}
<button
type="button"
onClick={() => removeUtensil(index)}
title={t("recipes.techStepCorrection.removeUtensil")}
disabled={isSubmitting}
>
</button>
</li>
);
})}
</ul>
<button type="button" onClick={() => requestSpan("utensil")} disabled={isSubmitting}>
{t("recipes.techStepCorrection.addUtensil")}
</button>
</section>
<button
type="button"
className="tech-step-correction-popover__confirm-button"
onClick={confirm}
disabled={isSubmitting || selectedTechStepId === null}
>
{t("recipes.techStepCorrection.confirm")}
</button>
</div>
)}
{error && <p className="field-error">{error}</p>}
<button
type="button"

View file

@ -2,26 +2,7 @@
"common": {
"saving": "Enregistrement…",
"saved": "Enregistré ✓",
"loadError": "Impossible de charger le planning, réessayez plus tard",
"weekNav": {
"thisWeek": "Cette semaine",
"prevWeek": "Semaine précédente",
"nextWeek": "Semaine suivante",
"label": "Semaine du {{range}}"
},
"calendar": {
"prevMonth": "Mois précédent",
"nextMonth": "Mois suivant"
},
"days": {
"lundi": "Lundi",
"mardi": "Mardi",
"mercredi": "Mercredi",
"jeudi": "Jeudi",
"vendredi": "Vendredi",
"samedi": "Samedi",
"dimanche": "Dimanche"
}
"loadError": "Impossible de charger le planning, réessayez plus tard"
},
"errors": {
"VALIDATION_ERROR": "Erreur de validation",
@ -44,7 +25,6 @@
"SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas",
"STEP_NOT_FOUND": "Cette étape n'existe pas",
"TECH_STEP_NOT_FOUND": "Cette technique n'existe pas",
"UTENSIL_NOT_FOUND": "Un des ustensiles sélectionnés n'existe pas",
"INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
},
@ -122,6 +102,25 @@
"planning": {
"title": "Planning de la semaine",
"loading": "Chargement du planning…",
"weekNav": {
"thisWeek": "Cette semaine",
"prevWeek": "Semaine précédente",
"nextWeek": "Semaine suivante",
"label": "Semaine du {{range}}"
},
"calendar": {
"prevMonth": "Mois précédent",
"nextMonth": "Mois suivant"
},
"days": {
"lundi": "Lundi",
"mardi": "Mardi",
"mercredi": "Mercredi",
"jeudi": "Jeudi",
"vendredi": "Vendredi",
"samedi": "Samedi",
"dimanche": "Dimanche"
},
"meals": {
"petit-dejeuner": "Petit-déjeuner",
"collation": "Collation",
@ -169,24 +168,7 @@
"removeMatch": "Aucune technique ici",
"cancel": "Annuler",
"manualTooltip": "{{technique}} (correction manuelle)",
"discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger.",
"confirm": "Valider",
"techniqueSection": "Technique",
"currentTechnique": "Technique retenue : {{technique}}",
"noTechniqueSelected": "Aucune technique sélectionnée.",
"searchTechniquePlaceholder": "Rechercher une technique…",
"noTechniqueFound": "Aucune technique trouvée.",
"ingredientsSection": "Ingrédients",
"utensilsSection": "Ustensiles",
"addIngredient": "+ Ajouter un ingrédient",
"addUtensil": "+ Ajouter un ustensile",
"removeIngredient": "Retirer cet ingrédient",
"removeUtensil": "Retirer cet ustensile",
"selectSpanHint": "Sélectionnez le passage de texte concerné dans la description ci-dessus…",
"cancelSpanSelection": "Annuler la sélection",
"searchUtensilPlaceholder": "Rechercher un ustensile…",
"noUtensilFound": "Aucun ustensile trouvé.",
"addToList": "Ajouter"
"discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger."
},
"tabs": {
"favoris": "Favoris",
@ -307,8 +289,7 @@
},
"shoppingList": {
"title": "Liste de courses",
"loading": "Chargement de la liste de courses…",
"empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning."
"comingSoon": "Cette section arrive bientôt."
},
"account": {
"title": "Compte",
@ -443,87 +424,7 @@
"preheat": "Préchauffer",
"bake": "Cuire au four",
"plate": "Dresser",
"coat": "Napper",
"baste": "Arroser",
"appertize": "Appertiser",
"whiskPale": "Blanchir (jaunes d'œufs)",
"goldenBrown": "Blondir",
"braise": "Braiser",
"truss": "Brider",
"caramelize": "Caraméliser",
"score": "Cerner",
"lineMold": "Chemiser",
"clarify": "Clarifier",
"compote": "Compoter",
"concasse": "Concasser",
"confit": "Confire",
"julienne": "Couper en julienne",
"brunoise": "Couper en brunoise",
"mirepoix": "Couper en mirepoix",
"paysanne": "Couper en paysanne",
"blindBake": "Cuire à blanc",
"bainMarie": "Cuire au bain-marie",
"smother": "Cuire à l'étouffée",
"decant": "Décanter",
"dilute": "Délayer",
"punchDown": "Dégazer",
"disgorge": "Dégorger",
"loosen": "Détendre",
"shellEgg": "Écaler",
"scald": "Échauder",
"pod": "Écosser",
"emulsify": "Émulsionner",
"hollowOut": "Évider",
"shock": "Frapper",
"setGel": "Gélifier",
"glaze": "Glacer",
"thicken": "Lier",
"filet": "Lever les filets",
"proof": "Laisser pousser",
"peelBlanch": "Monder",
"whipUp": "Monter",
"moisten": "Mouiller",
"pasteurize": "Pasteuriser",
"poach": "Pocher",
"reduce": "Réduire",
"rubIn": "Sabler",
"dustWithFlour": "Singer",
"sweat": "Suer",
"sift": "Tamiser",
"toast": "Torréfier",
"zest": "Zester"
},
"utensils": {
"pan": "Poêle",
"saucepan": "Casserole",
"pot": "Marmite",
"knife": "Couteau",
"whisk": "Fouet",
"bowl": "Saladier",
"bakingSheet": "Plaque de cuisson",
"mold": "Moule",
"colander": "Passoire",
"cuttingBoard": "Planche à découper",
"oven": "Four",
"blender": "Blender",
"mixer": "Batteur",
"spatula": "Spatule",
"ladle": "Louche",
"grater": "Râpe",
"rollingPin": "Rouleau à pâtisserie",
"lid": "Couvercle",
"tongs": "Pince de cuisine",
"peeler": "Économe",
"sieve": "Tamis",
"foodProcessor": "Robot ménager",
"steamerBasket": "Panier vapeur",
"skewer": "Brochette",
"pastryBrush": "Pinceau de cuisine",
"ramekin": "Ramequin",
"dish": "Plat",
"wok": "Wok",
"thermometer": "Thermomètre",
"mandoline": "Mandoline"
"coat": "Napper"
},
"allergens": {
"gluten": "Gluten",

View file

@ -1,4 +1,11 @@
import { DateTime, formatDateOnly, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
import {
addWeeks,
buildCalendarMonth,
DateTime,
formatDateOnly,
getWeekStart,
toDateOnly,
} from "@batch-cooking/date-tools";
import {
MEALS,
type Meal,
@ -6,11 +13,10 @@ import {
type PlanningView,
WEEK_DAYS,
} from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiClient } from "../../api/client";
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
import { WeekNavigator } from "../../features/planning/WeekNavigator";
import "./planning-page.scss";
/** Load state for the `GET /planning` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */
@ -43,7 +49,7 @@ export function PlanningPage() {
// closed. Mounting the dialog only while this is set (rather than an
// always-mounted `isOpen` toggle) resets its internal filter/search
// state for free on every open, same convention as `WeekNavigator`'s own
// `CalendarPopover` (features/planning/WeekNavigator.tsx).
// `CalendarPopover` below.
const [openSlot, setOpenSlot] = useState<PlanningSlot | null>(null);
useEffect(() => {
@ -138,6 +144,160 @@ export function PlanningPage() {
);
}
/** "17 au 23 août 2026" — collapses the month/year to just the end date when both ends of the week share it, spells it out on both ends otherwise (e.g. a week straddling two months). */
function formatWeekRange(weekStart: DateTime): string {
const weekEnd = weekStart.plus({ days: 6 });
const sameMonth = weekStart.hasSame(weekEnd, "month");
const startLabel = weekStart.toLocaleString(
sameMonth ? { day: "numeric" } : { day: "numeric", month: "long" },
{ locale: "fr" },
);
const endLabel = weekEnd.toLocaleString(
{ day: "numeric", month: "long", year: "numeric" },
{ locale: "fr" },
);
return `${startLabel} au ${endLabel}`;
}
/** Arrows + clickable label opening {@link CalendarPopover} — the week-selection UI at the top of the page. */
function WeekNavigator({
weekStart,
onChangeWeek,
}: {
weekStart: DateTime;
onChangeWeek: (weekStart: DateTime) => void;
}) {
const { t } = useTranslation();
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const isThisWeek = weekStart.hasSame(getWeekStart(DateTime.utc()), "day");
return (
<div className="week-nav">
<button
type="button"
className="week-nav__arrow"
title={t("planning.weekNav.prevWeek")}
onClick={() => onChangeWeek(addWeeks(weekStart, -1))}
>
</button>
<button
type="button"
className="week-nav__label"
onClick={() => setIsCalendarOpen((open) => !open)}
>
📅 {t("planning.weekNav.label", { range: formatWeekRange(weekStart) })}
{isThisWeek && <span className="today-badge">{t("planning.weekNav.thisWeek")}</span>}
</button>
<button
type="button"
className="week-nav__arrow"
title={t("planning.weekNav.nextWeek")}
onClick={() => onChangeWeek(addWeeks(weekStart, 1))}
>
</button>
{isCalendarOpen && (
<CalendarPopover
selectedWeekStart={weekStart}
onSelectDay={(day) => {
onChangeWeek(getWeekStart(day));
setIsCalendarOpen(false);
}}
onClose={() => setIsCalendarOpen(false)}
/>
)}
</div>
);
}
/** Month calendar letting the visitor jump to any week at once — selecting a day selects its whole (Monday-first) week. Closes itself on an outside click. */
function CalendarPopover({
selectedWeekStart,
onSelectDay,
onClose,
}: {
selectedWeekStart: DateTime;
onSelectDay: (day: DateTime) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
// Its own state: browsing to a different month to pick a week there
// shouldn't jump back every render — only re-anchors when the popover is
// first opened (`selectedWeekStart` at that point), not while it's open.
const [visibleMonth, setVisibleMonth] = useState(() => selectedWeekStart.startOf("month"));
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const today = toDateOnly(DateTime.utc());
const selectedWeekEnd = selectedWeekStart.plus({ days: 6 });
const weeks = buildCalendarMonth(visibleMonth);
return (
<div className="calendar-popover" ref={popoverRef}>
<div className="calendar-popover__header">
<button
type="button"
title={t("planning.calendar.prevMonth")}
onClick={() => setVisibleMonth((month) => month.minus({ months: 1 }))}
>
</button>
<span>
{visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })}
</span>
<button
type="button"
title={t("planning.calendar.nextMonth")}
onClick={() => setVisibleMonth((month) => month.plus({ months: 1 }))}
>
</button>
</div>
<div className="calendar-grid">
{WEEK_DAYS.map((weekDay) => (
<span key={weekDay} className="calendar-grid__weekday">
{t(`planning.days.${weekDay}`).charAt(0)}
</span>
))}
{weeks.flat().map((day) => {
const classNames = ["calendar-grid__day"];
if (!day.hasSame(visibleMonth, "month")) classNames.push("calendar-grid__day--muted");
if (day >= selectedWeekStart && day <= selectedWeekEnd) {
classNames.push("calendar-grid__day--in-selected-week");
}
if (day.hasSame(today, "day")) classNames.push("calendar-grid__day--today");
return (
<button
key={day.toISO()}
type="button"
className={classNames.join(" ")}
onClick={() => onSelectDay(day)}
>
{day.day}
</button>
);
})}
</div>
</div>
);
}
/** The week grid itself — 7 day columns × 5 meal rows. */
function PlanningGrid({
weekStart,
@ -163,7 +323,7 @@ function PlanningGrid({
<th />
{days.map(({ weekDay, date }) => (
<th key={weekDay} className={date.hasSame(today, "day") ? "today" : undefined}>
<span className="day-name">{t(`common.days.${weekDay}`)}</span>
<span className="day-name">{t(`planning.days.${weekDay}`)}</span>
<span className="day-date">{date.day}</span>
</th>
))}

View file

@ -38,11 +38,143 @@
}
}
// --- Week navigator (arrows + clickable label opening the calendar) -------
.week-nav {
position: relative;
display: flex;
align-items: center;
gap: var(--space-xs);
&__arrow {
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--font-size-md);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
}
&__label {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 0.45rem var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font-weight: 600;
font-size: var(--font-size-sm);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
}
}
.today-badge {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
padding: 0.1rem 0.4rem;
border-radius: var(--radius-pill);
}
// --- Calendar popover -------------------------------------------------------
.calendar-popover {
position: absolute;
top: calc(100% + var(--space-xs));
right: 0;
z-index: 10;
width: 18rem;
padding: var(--space-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-sm);
font-weight: 700;
font-size: var(--font-size-sm);
text-transform: capitalize;
button {
width: 1.6rem;
height: 1.6rem;
border: none;
background: none;
cursor: pointer;
font-size: var(--font-size-base);
color: var(--color-text-muted);
border-radius: var(--radius-base);
&:hover {
background: var(--color-surface-alt);
}
}
}
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
&__weekday {
text-align: center;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-weight: 600;
padding-bottom: var(--space-xs);
}
&__day {
aspect-ratio: 1;
display: grid;
place-items: center;
font-size: var(--font-size-sm);
border-radius: var(--radius-base);
cursor: pointer;
color: var(--color-text);
border: none;
background: none;
font: inherit;
&:hover {
background: var(--color-surface-alt);
}
&--muted {
color: var(--color-border);
}
&--in-selected-week {
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border-radius: 0;
}
&--today {
box-shadow: inset 0 0 0 2px var(--color-primary);
font-weight: 700;
}
}
}
// --- The grid itself --------------------------------------------------------
// (Week navigator + calendar popover styles now live in
// features/planning/week-navigator.scss, imported by WeekNavigator.tsx
// directly extracted once ShoppingListPage started reusing that
// component too.)
.planning-grid-wrapper {
flex: 1;
min-height: 0;

View file

@ -1,114 +1,10 @@
import { DateTime, formatDateOnly, getWeekStart } from "@batch-cooking/date-tools";
import type { ShoppingListItemView, ShoppingListView } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiClient } from "../../api/client";
import { WeekNavigator } from "../../features/planning/WeekNavigator";
import {
CategoryIcon,
IngredientTypeIcon,
} from "../../features/recipes/ingredients/ingredient-icons";
import { formatShoppingListQuantity, groupShoppingListItems } from "./shopping-list";
import "./shopping-list-page.scss";
import { ComingSoonPage } from "../../components/ui/ComingSoonPage";
/** Load state for the `GET /shopping-list` call — same discriminated-union shape as `PlanningPage`'s own `PlanningState`. */
type ShoppingListState =
| { status: "loading" }
| { status: "loaded"; list: ShoppingListView }
| { status: "error" };
/**
* Shopping list section routed at `/liste-de-courses`. Every ingredient
* line of every recipe planned for a selectable week, aggregated server-side
* (`GET /shopping-list`, see the API's `shopping-list.service.ts`) into one
* quantity per (ingredient, unit) pair, grouped by supermarket aisle for
* display. Deliberately simple by design a read-only list, no
* checkboxes/crossing-off state: the source of truth for what's needed is
* the planning itself, not a separate to-do list this page would have to
* keep in sync with it.
*/
/** Shopping list section — routed at `/liste-de-courses`. No backend yet, stub for now. */
export function ShoppingListPage() {
const { t } = useTranslation();
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
const [state, setState] = useState<ShoppingListState>({ status: "loading" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
apiClient
.getShoppingListForWeek(formatDateOnly(weekStart))
.then((list) => {
if (!cancelled) setState({ status: "loaded", list });
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [weekStart]);
return (
<div className="shopping-list-page">
<div className="shopping-list-page__header">
<h1>{t("shoppingList.title")}</h1>
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
</div>
{state.status === "loading" && (
<p className="shopping-list-page__status">{t("shoppingList.loading")}</p>
)}
{state.status === "error" && (
<p className="shopping-list-page__status shopping-list-page__status--error">
{t("common.loadError")}
</p>
)}
{state.status === "loaded" && <ShoppingListItems items={state.list.items} />}
</div>
);
}
/** The list itself, grouped by aisle (see `groupShoppingListItems`) — or the empty-week message if nothing's planned. */
function ShoppingListItems({ items }: { items: ShoppingListItemView[] }) {
const { t } = useTranslation();
if (items.length === 0) {
return <p className="shopping-list-page__status">{t("shoppingList.empty")}</p>;
}
const groups = groupShoppingListItems(items, (item) =>
t(`catalog.ingredients.${item.ingredient.key}`),
);
return (
<div className="shopping-list">
{groups.map((group) => (
<section key={group.category} className="shopping-list__group">
<h2 className="shopping-list__group-title">
<CategoryIcon category={group.category} />
{t(`recipes.form.category.${group.category}`)}
</h2>
<ul className="shopping-list__items">
{group.items.map((item) => (
<li key={`${item.ingredient.id}-${item.unit.id}`} className="shopping-list__item">
<span className="shopping-list__item-icon" aria-hidden="true">
<IngredientTypeIcon icon={item.ingredient.icon} />
</span>
<span className="shopping-list__item-name">
{t(`catalog.ingredients.${item.ingredient.key}`)}
</span>
<span className="shopping-list__item-quantity">
{formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)}
</span>
</li>
))}
</ul>
</section>
))}
</div>
<ComingSoonPage title={t("shoppingList.title")} description={t("shoppingList.comingSoon")} />
);
}

View file

@ -1,102 +0,0 @@
// =============================================================================
// Styles specific to ShoppingListPage colocated next to
// ShoppingListPage.tsx since nothing else uses these classes. Same page
// shell/status conventions as planning-page.scss (`.planning-page__header`/
// `__status`), a simple grouped list rather than a grid below it.
// =============================================================================
.shopping-list-page {
height: 100%;
display: flex;
flex-direction: column;
&__header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
&__status {
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
&__status--error {
color: var(--color-error);
}
}
// --- The list itself, grouped by aisle --------------------------------------
.shopping-list {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.shopping-list__group-title {
display: flex;
align-items: center;
gap: var(--space-xs);
margin: 0 0 var(--space-sm);
font-size: var(--font-size-md);
font-weight: 700;
color: var(--color-text);
svg {
width: 1.2rem;
height: 1.2rem;
color: var(--color-text-muted);
}
}
.shopping-list__items {
display: flex;
flex-direction: column;
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.shopping-list__item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--color-border);
&:last-child {
border-bottom: none;
}
&-icon {
flex-shrink: 0;
display: grid;
place-items: center;
color: var(--color-text-muted);
svg {
width: 1.25rem;
height: 1.25rem;
}
}
&-name {
flex: 1;
min-width: 0;
color: var(--color-text);
}
&-quantity {
flex-shrink: 0;
font-weight: 600;
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
}

View file

@ -1,64 +0,0 @@
import {
INGREDIENT_CATEGORIES,
type IngredientCategory,
type ShoppingListItemView,
} from "@batch-cooking/shared";
/** One aisle's worth of shopping list lines — see {@link groupShoppingListItems}. */
export interface ShoppingListGroup {
category: IngredientCategory;
items: ShoppingListItemView[];
}
/**
* Groups `items` by their ingredient's supermarket-aisle category (the same
* `IngredientCategory` the recipe form's `IngredientPicker` already browses
* by, see `ingredient-icons.tsx`'s `CategoryIcon`), in the app's canonical
* `INGREDIENT_CATEGORIES` order a shopping list read aisle-by-aisle is far
* more useful in-store than one flat list. Within a group, lines are sorted
* by `ingredientLabel` the caller's *already-translated* display name for
* that line, not the untranslated English `key` so alphabetical order
* reads correctly in French; kept as a parameter (rather than calling
* `useTranslation` in here) so this stays a pure function the component can
* unit test without mounting i18next, same "logic extracted from the .tsx"
* split as every other feature in this codebase.
*/
export function groupShoppingListItems(
items: ShoppingListItemView[],
ingredientLabel: (item: ShoppingListItemView) => string,
): ShoppingListGroup[] {
const byCategory = new Map<IngredientCategory, ShoppingListItemView[]>();
for (const item of items) {
const category = item.ingredient.category;
const group = byCategory.get(category);
if (group) {
group.push(item);
} else {
byCategory.set(category, [item]);
}
}
const groups: ShoppingListGroup[] = [];
for (const category of INGREDIENT_CATEGORIES) {
const groupItems = byCategory.get(category);
if (!groupItems) continue;
groups.push({
category,
items: [...groupItems].sort((a, b) =>
ingredientLabel(a).localeCompare(ingredientLabel(b), "fr"),
),
});
}
return groups;
}
/**
* Formats an aggregated quantity for display French grouping/decimal
* conventions, at most 2 decimals (e.g. `"1,5"`, `"250"`) so summing several
* recipes' quantities (`shopping-list.service.ts`'s `aggregateShoppingList`,
* floating-point addition) never surfaces a long trailing-digit artifact
* like `"149.99999999999997"`.
*/
export function formatShoppingListQuantity(quantity: number): string {
return quantity.toLocaleString("fr-FR", { maximumFractionDigits: 2 });
}

View file

@ -27,6 +27,9 @@ services:
context: .
dockerfile: apps/api/Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 3000
@ -48,63 +51,8 @@ services:
# default: `/internal/tech-steps/*` fails closed rather than open
# for a deployment that doesn't run the worker at all.
INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-}
# Compose network service name, not localhost — same reasoning as
# DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback:
# tech-step-intent-service is a core dependency (see its own entry
# below), not an optional background job.
INTENT_SERVICE_BASE_URL: "http://tech-step-intent-service:8000"
INTENT_SERVICE_SECRET: ${INTENT_SERVICE_SECRET:?set INTENT_SERVICE_SECRET in .env}
ports:
- "${APP_PORT:-3000}:3000"
depends_on:
postgres:
condition: service_healthy
tech-step-intent-service:
condition: service_healthy
# spaCy-based NER + intent classification microservice
# (services/tech-step-intent-service) — `app` delegates all tech-step
# detection to it over HTTP (see `IntentServiceClient`,
# apps/api/src/lib/recipe-matching/intent-service-client.ts). Unlike
# `tech-step-llm-worker` below, **not optional**: without it, `app` can no
# longer detect any cooking technique in a recipe step at all. No exposed
# port — reachable only from `app` on the compose network, nothing ever
# calls into it from outside.
tech-step-intent-service:
build:
context: .
dockerfile: services/tech-step-intent-service/Dockerfile
restart: unless-stopped
environment:
INTENT_SERVICE_SECRET: ${INTENT_SERVICE_SECRET:?set INTENT_SERVICE_SECRET in .env}
healthcheck:
# No curl/wget in the python:3.12-slim base image — a one-line Python
# request is the healthcheck for a service that's already guaranteed
# to have Python (see this service's Dockerfile).
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2)",
]
interval: 15s
timeout: 3s
retries: 5
# This service trains itself from scratch on every start (no model
# ever persisted to disk, see its own README) — `/health` only
# returns 200 once that's done, not just once the base spaCy models
# are loaded. Measured at ~540s (fr) / ~390s (en), ~930s combined,
# against the current ~74-technique corpus — each technique now has
# the *same* number of `utterances` per locale as every other
# (equalized to the corpus's own pre-existing max, 7/5 — see
# `training_data.py`'s own doc comment for why a flat, larger target
# like 20 was tried and reverted) — `start_period` generous enough
# that failing checks during that whole window never count against
# `retries` (which would otherwise flip this container to
# "unhealthy" mid-training, blocking `app`'s own `depends_on:
# condition: service_healthy` indefinitely).
start_period: 1200s
# Deliberately its own image, not built into `app`'s (see
# services/tech-step-llm-worker/Dockerfile's own doc comment) — a

View file

@ -68,8 +68,6 @@ export enum ErrorCode {
STEP_NOT_FOUND = 4050,
/** A tech-step correction's `previousTechStepId`/`correctedTechStepId` doesn't match any reference `TechStep` row. */
TECH_STEP_NOT_FOUND = 4051,
/** A tech-step correction's manually-attached `utensils[].utensilId` doesn't match any reference `Utensil` row. */
UTENSIL_NOT_FOUND = 4052,
/** A tech-step correction's `start`/`end` span falls outside the target step's `description`, or `start >= end`. */
INVALID_CORRECTION_SPAN = 4002,
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */

View file

@ -13,7 +13,6 @@ export * from "./schemas/planning.js";
export * from "./schemas/preferences.js";
export * from "./schemas/profile.js";
export * from "./schemas/recipe.js";
export * from "./schemas/shopping-list.js";
export * from "./schemas/sources.js";
export * from "./schemas/tech-step-worker.js";
export * from "./tools/assert-is-never.js";
@ -22,7 +21,6 @@ export * from "./types/planning.js";
export * from "./types/preferences.js";
export * from "./types/recipe.js";
export * from "./types/reference.js";
export * from "./types/shopping-list.js";
export * from "./types/sources.js";
export * from "./types/tech-step-worker.js";
export * from "./types/user-profile.js";

View file

@ -117,41 +117,6 @@ export const listRecipesSchema = z.object({
/** Inferred TS type for {@link listRecipesSchema}'s validated output. */
export type ListRecipesInput = z.infer<typeof listRecipesSchema>;
/**
* One ingredient mention the user themselves points at while correcting a
* technique `start`/`end` is *their own* selection of the exact passage
* of `description` that names it (a separate selection from the
* correction's own `[start, end)`, see `TechStepCorrectionPopover.tsx`),
* not derived from anything the classifier found. `quantity`/`unitId`
* are optional a mention with no quantity attached ("ajouter le sel")
* is still worth recording. See `submitTechStepCorrectionSchema`'s own
* doc comment for how `ingredients` as a whole behaves.
*/
const manualStepTechStepIngredientInputSchema = z
.object({
ingredientId: z.number().int().positive(),
quantity: z.number().positive("La quantité doit être positive").nullable().optional(),
unitId: z.number().int().positive().nullable().optional(),
start: z.number().int().nonnegative(),
end: z.number().int().nonnegative(),
})
.refine((ingredient) => ingredient.end > ingredient.start, {
message: "end must be greater than start",
path: ["end"],
});
/** A utensil mention the user points at while correcting a technique — same `start`/`end` convention as {@link manualStepTechStepIngredientInputSchema}, no quantity/unit (nothing to measure for a utensil). */
const manualStepTechStepUtensilInputSchema = z
.object({
utensilId: z.number().int().positive(),
start: z.number().int().nonnegative(),
end: z.number().int().nonnegative(),
})
.refine((utensil) => utensil.end > utensil.start, {
message: "end must be greater than start",
path: ["end"],
});
/**
* Payload accepted by `POST /recipes/:id/steps/:stepId/corrections` a
* user asserting what technique a `[start, end)` span of a step's
@ -164,19 +129,6 @@ const manualStepTechStepUtensilInputSchema = z
* (`recipe-tech-step-correction.service.ts`) needs the target step's
* `description` length to validate `start`/`end` against, which this shape
* alone can't see.
*
* `ingredients`/`utensils` let the user attach metadata to the technique
* they're asserting (`correctedTechStepId`), same `source: "manual"`
* distinction the technique itself gets. **Omitted (`undefined`) means
* "leave whatever metadata already exists on this occurrence alone"
* an explicit array, even `[]`, means "this is now the complete set,
* replace everything that was there" (auto-detected included; see
* `applyManualCorrection`'s own doc comment). This is why neither field
* has a `.default([])`: that would silently turn every plain relabel into
* a metadata wipe.** Only meaningful alongside a real `correctedTechStepId`
* enforced by this schema's own refine below, since there's no live
* `StepTechStep` row to attach to otherwise (removing a match, or a
* request with neither id set).
*/
export const submitTechStepCorrectionSchema = z
.object({
@ -184,8 +136,6 @@ export const submitTechStepCorrectionSchema = z
end: z.number().int().nonnegative(),
previousTechStepId: z.number().int().positive().nullable().optional(),
correctedTechStepId: z.number().int().positive().nullable().optional(),
ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(),
utensils: z.array(manualStepTechStepUtensilInputSchema).optional(),
})
.refine((input) => input.end > input.start, {
message: "end must be greater than start",
@ -198,15 +148,6 @@ export const submitTechStepCorrectionSchema = z
message: "at least one of previousTechStepId/correctedTechStepId is required",
path: ["correctedTechStepId"],
},
)
.refine(
(input) =>
(input.ingredients === undefined && input.utensils === undefined) ||
(input.correctedTechStepId ?? null) !== null,
{
message: "ingredients/utensils require a correctedTechStepId to attach to",
path: ["correctedTechStepId"],
},
);
/** Inferred TS type for {@link submitTechStepCorrectionSchema}'s validated output. */
export type SubmitTechStepCorrectionInput = z.infer<typeof submitTechStepCorrectionSchema>;

View file

@ -1,19 +0,0 @@
import { z } from "zod";
// See schemas/auth.ts for the shared client/server validation rationale.
/**
* Payload accepted by `GET /shopping-list`'s `?date=` query param same
* shape/rationale as `schemas/planning.ts`'s `getPlanningByDateSchema`
* (only checks the `YYYY-MM-DD` shape, real-calendar-date validation is
* service-side via `@batch-cooking/date-tools`'s `parseDateOnly`). Kept as
* its own schema rather than importing `getPlanningByDateSchema` each
* router module owns its own request contract in this repo, even when two
* happen to share a shape (see the two near-identical `date` fields already
* inside `schemas/planning.ts` itself).
*/
export const getShoppingListSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date invalide"),
});
/** Inferred TS type for {@link getShoppingListSchema}'s validated output. */
export type GetShoppingListInput = z.infer<typeof getShoppingListSchema>;

View file

@ -1,11 +1,4 @@
import type {
AllergyView,
DietView,
IngredientView,
TechStepView,
UnitView,
UtensilView,
} from "./reference.js";
import type { AllergyView, DietView, IngredientView, TechStepView, UnitView } from "./reference.js";
/**
* Who can *read* a recipe mirrors `RecipeVisibility` in schema.prisma.
@ -56,11 +49,6 @@ export interface RecipeIngredientView {
* immediately (`recipe-tech-step-correction.service.ts`'s
* `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.
*/
export interface StepTechStepView {
techStep: TechStepView;
@ -69,38 +57,6 @@ export interface StepTechStepView {
contextStart?: number;
contextEnd?: number;
source: "auto" | "manual";
ingredients: StepTechStepIngredientView[];
utensils: StepTechStepUtensilView[];
}
/**
* An ingredient mentioned in the same clause as a detected technique (see
* {@link StepTechStepView.ingredients}) `quantity`/`unit` are `null` when
* none was recognized immediately before the mention (e.g. "ajouter le
* sel"), same "best-effort, not always present" contract as
* `tech-step-matcher.ts`'s `IngredientMention`. `start`/`end` are the
* mention's own span in the step's `description`, same `[start, end)`
* convention as {@link StepTechStepView.start}.
*
* `source` mirrors {@link StepTechStepView.source} `"auto"` is the
* classifier's own detection, `"manual"` is a viewer's own selection
* (`SubmitTechStepCorrectionInput.ingredients`, `TechStepCorrectionPopover.tsx`).
*/
export interface StepTechStepIngredientView {
ingredient: IngredientView;
quantity: number | null;
unit: UnitView | null;
start: number;
end: number;
source: "auto" | "manual";
}
/** A utensil mentioned in the same clause as a detected technique (see {@link StepTechStepView.utensils}) — `source` mirrors {@link StepTechStepIngredientView.source}. */
export interface StepTechStepUtensilView {
utensil: UtensilView;
start: number;
end: number;
source: "auto" | "manual";
}
/**

View file

@ -210,24 +210,6 @@ export interface TechStepView {
key: string;
}
/**
* A cooking utensil, as returned by `GET /reference/utensils` reference
* data (`Utensil`, seeded via `reference-seed-data.ts`'s `UTENSILS`), same
* bare `id`+`key` shape and static/non-administrable status as
* {@link TechStepView}. Detected in a step's free text the same way
* techniques are (see `StepTechStepUtensilView`), but via a static
* `PhraseMatcher` rather than a trained classifier see
* `services/tech-step-intent-service`'s `utensil_vocabulary.py`.
*
* `key` is a stable English camelCase uid (e.g. `"pan"`), not a display
* label resolved via `t(\`catalog.utensils.${key}\`)`, same as
* {@link TechStepView.key}.
*/
export interface UtensilView {
id: number;
key: string;
}
/**
* An implemented recipe source, as returned by `GET /reference/sources`
* reference data (`Source`, kept in sync with the adapter registry by

View file

@ -1,37 +0,0 @@
import type { IngredientView, UnitView } from "./reference.js";
/**
* One aggregated ingredient line in a shopping list every
* `RecipeIngredient` line of every recipe planned for the week, summed
* across recipes/planning items. `quantity` already accounts for each
* planning item's own portion count (`RecipeIngredient.quantity ×
* PlanningItem.portions / Recipe.portions`, see the API's
* `shopping-list.service.ts`), so this is the real amount to buy, not the
* recipe's as-written quantity.
*
* Quantities are only ever summed when both `ingredient` **and** `unit`
* match exactly `UnitView.toBaseFactor` exists as groundwork for a future
* cross-unit conversion (e.g. summing "500g" + "0.5kg" into "1kg"), not yet
* built (see that field's own doc comment), so the same ingredient
* requested in two different units surfaces as two separate lines rather
* than silently guessing a conversion.
*/
export interface ShoppingListItemView {
ingredient: IngredientView;
quantity: number;
unit: UnitView;
}
/**
* A household's shopping list for the week starting `startDate`, as
* returned by `GET /shopping-list`. Unlike `PlanningView`, this is
* **never** `null` a caller with no household, or whose household has no
* planning for that week yet, both degrade to an empty `items` array
* (nothing to shop for is a normal state to render directly, not a
* separate "no list" case to branch on).
*/
export interface ShoppingListView {
startDate: string;
finishDate: string;
items: ShoppingListItemView[];
}

View file

@ -44,6 +44,9 @@ importers:
jsonwebtoken:
specifier: ^9.0.3
version: 9.0.3
node-nlp:
specifier: 4.27.0
version: 4.27.0
prisma:
specifier: ^5.22.0
version: 5.22.0
@ -850,12 +853,224 @@ packages:
resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==, tarball: https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz}
hasBin: true
'@microsoft/recognizers-text-choice@1.3.1':
resolution: {integrity: sha512-HubunMJVq/OetmdvcAmBh5skMlg+yiScm3V2wNyNZIVvLgli4+8nzbg/W/fI9dpaf6wv9ZQ7d2IYvn8swJBo3A==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-choice/-/recognizers-text-choice-1.3.1.tgz}
engines: {node: '>=10.3.0'}
'@microsoft/recognizers-text-data-types-timex-expression@1.3.1':
resolution: {integrity: sha512-jarJIFIJZBqeofy3hh0vdQo1yOmTM+jCjj6/zmo9JunsQ6LO750eZHCg9eLptQhsvq321XCt5xdRNLCwU8YeNA==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-data-types-timex-expression/-/recognizers-text-data-types-timex-expression-1.3.1.tgz}
engines: {node: '>=10.3.0'}
'@microsoft/recognizers-text-date-time@1.3.2':
resolution: {integrity: sha512-fUEGOTccS55ZY0erzjS1bunJYA9lGXjcZoru5oPOlnxbJS4Lk0ylgdH2Ub2EjAyqr8DIJhdLNOEesCdAXMvlNg==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-date-time/-/recognizers-text-date-time-1.3.2.tgz}
engines: {node: '>=10.3.0'}
'@microsoft/recognizers-text-number-with-unit@1.3.1':
resolution: {integrity: sha512-gzCpPP4zQ5Vb+RHaWjzP2t1c+mj6GYOsFoI2NyJkm8OZ52XI+x9SJCgrrD2ujzjOd5/CQVC46rE22rfGwXLDkA==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-number-with-unit/-/recognizers-text-number-with-unit-1.3.1.tgz}
engines: {node: '>=10.3.0'}
'@microsoft/recognizers-text-number@1.3.1':
resolution: {integrity: sha512-JBxhSdihdQLQilCtqISEBw5kM+CNGTXzy5j5hNoZECNUEvBUPkAGNEJAeQPMP5abrYks29aSklnSvSyLObXaNQ==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-number/-/recognizers-text-number-1.3.1.tgz}
engines: {node: '>=10.3.0'}
'@microsoft/recognizers-text-sequence@1.3.1':
resolution: {integrity: sha512-J7Kg35hpm0NcFHmu69Bb4q7DPDiSpCd8ApUZqNm59itIjrQJHpSdl9HF6JxuQQz0Ftc/li5ZLqSuupJAmA/sgg==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-sequence/-/recognizers-text-sequence-1.3.1.tgz}
engines: {node: '>=10.3.0'}
'@microsoft/recognizers-text-suite@1.3.0':
resolution: {integrity: sha512-uqG4vzy5N2CmBaeINny0bLdnGp0jDbT1moNoLC+Yim3G8kHOU9lpDfwA6VN6HTYaDM5854SNMEzLjJdS1TPFTw==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-suite/-/recognizers-text-suite-1.3.0.tgz}
engines: {node: '>=10.3.0'}
'@microsoft/recognizers-text@1.3.1':
resolution: {integrity: sha512-HikLoRUgSzM4OKP3JVBzUUp3Q7L4wgI17p/3rERF01HVmopcujY3i6wgx8PenCwbenyTNxjr1AwSDSVuFlYedQ==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text/-/recognizers-text-1.3.1.tgz}
engines: {node: '>=10.3.0'}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==, tarball: https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz}
engines: {node: ^22.20 || ^24.12 || >=25}
cpu: [x64]
os: [linux]
'@nlpjs/builtin-duckling@4.26.1':
resolution: {integrity: sha512-3qkH955X2g5MXV1EqT3fTAT/lLEdiqqe5IgBDyr+MQB7FOV9R3YhqGIn3DFOl+TSm/tP5n/BAEptkTNn/TOpmQ==, tarball: https://registry.npmjs.org/@nlpjs/builtin-duckling/-/builtin-duckling-4.26.1.tgz}
'@nlpjs/builtin-microsoft@4.26.1':
resolution: {integrity: sha512-AODgzTcfYUf5Ozm00aQnHImDum7Idtl0F9dSPoaXpfj7rZqP8hPZ7iWwdGTAvISH/da2YhjPOU65QSYk2YpjFA==, tarball: https://registry.npmjs.org/@nlpjs/builtin-microsoft/-/builtin-microsoft-4.26.1.tgz}
'@nlpjs/core-loader@4.26.1':
resolution: {integrity: sha512-IiRtn65bdiUSQHy2kusco2fmhk39u2Mc2c5Fsm9+9EVG6BtJCmVEFU/btAzGDAmxEA/E4qKecaAT4LvcW6TPbA==, tarball: https://registry.npmjs.org/@nlpjs/core-loader/-/core-loader-4.26.1.tgz}
'@nlpjs/core@4.26.1':
resolution: {integrity: sha512-M/PeFddsi3y7Z1piFJxsLGm5/xdMhcrpOsml7s6CTEgYo8iduaT30HDd61tZxDyvvJseU6uFqlXSn7XKkAcC1g==, tarball: https://registry.npmjs.org/@nlpjs/core/-/core-4.26.1.tgz}
'@nlpjs/emoji@4.26.1':
resolution: {integrity: sha512-Q0PoXwIvaB1bnRXK4U/YD7mrqaz29Yfed3s2au0iXl1bffUgoG+hs4GORCvyy7DFCCLlc9d5yDM3oLIX/ggZ+Q==, tarball: https://registry.npmjs.org/@nlpjs/emoji/-/emoji-4.26.1.tgz}
'@nlpjs/evaluator@4.26.1':
resolution: {integrity: sha512-WeUrC8qq7+V8Jhkkjc2yiXdzy9V0wbETv8/qasQmL0QmEuwBDJF+fvfl4z2vWpBb0vW07A8aNrFElKELzbpkdg==, tarball: https://registry.npmjs.org/@nlpjs/evaluator/-/evaluator-4.26.1.tgz}
'@nlpjs/lang-all@4.26.1':
resolution: {integrity: sha512-UzRm1JRRAyQqilEOxQ2ySMOitKbhPk5iKYbjD8FREDcPjreUvDxVuQsYUOvYucmEyFcZU2U/TdJx+fX9/bcaKQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-all/-/lang-all-4.26.1.tgz}
'@nlpjs/lang-ar@4.26.1':
resolution: {integrity: sha512-MUlVtabt9ltG7WyzCQpFJymLJlnEqp3mxhgN9JHyFH7oZMK3REvMovFfvEUAbfiYrJEv/BN5KKLL7yrvUeaHtg==, tarball: https://registry.npmjs.org/@nlpjs/lang-ar/-/lang-ar-4.26.1.tgz}
'@nlpjs/lang-bn@4.26.1':
resolution: {integrity: sha512-sim1iZKBDdehi/yBUKrLW51QvS9uB+sXW7lj+THVqBy5UsnEQvt4gzE0NsC873uJMh66vt2AlHkhzgPH0qH/nQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-bn/-/lang-bn-4.26.1.tgz}
'@nlpjs/lang-ca@4.26.1':
resolution: {integrity: sha512-fD4R5tcAB0uYtNxSEF20b1KmF6nUQSbiJqrIUJI5yis4ObjCYRQnSh4bjVDKUKxyONjbD6L8EaK5GrY1/jkwFQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-ca/-/lang-ca-4.26.1.tgz}
'@nlpjs/lang-cs@4.26.1':
resolution: {integrity: sha512-CqI6VB8toaJ/MlP1D4K9BctA6GpZJhMKyEy+OX9xavDe4r4ao/SxlSaIYK3izK0k+J38lJWC5lXYGazfCdTGjA==, tarball: https://registry.npmjs.org/@nlpjs/lang-cs/-/lang-cs-4.26.1.tgz}
'@nlpjs/lang-da@4.26.1':
resolution: {integrity: sha512-krI/ojeDSi329ENM/hLIsbUh1x4XRTKAbtPcbFxAY6XVhcSVoWPO7L77jFTL1NQeE1oGRFzGHaeC9hZJ8phVbA==, tarball: https://registry.npmjs.org/@nlpjs/lang-da/-/lang-da-4.26.1.tgz}
'@nlpjs/lang-de@4.26.1':
resolution: {integrity: sha512-HfZQwsE5FICq9taVZDiyktmdAePVF5948NM80et0d9mx43RWDFhHKQYgtJPwfQXtdCoQtOM5TOJ2FanGwzPeaA==, tarball: https://registry.npmjs.org/@nlpjs/lang-de/-/lang-de-4.26.1.tgz}
'@nlpjs/lang-el@4.26.1':
resolution: {integrity: sha512-pcOvuSwPCXxI+2xNZZzM4V5pTRDntYoJi0SP/ic2nV4IPQ0nU2j16dYfg1HlvET/E6iN1VTqghrCaf10SMkDGA==, tarball: https://registry.npmjs.org/@nlpjs/lang-el/-/lang-el-4.26.1.tgz}
'@nlpjs/lang-en-min@4.26.1':
resolution: {integrity: sha512-1sJZ7dy7ysqzbsB8IklguvB88J8EPIv4XGVkZCcwecKtOw+fp5LAsZ3TJVmEf18iK1gD4cEGr7qZg5fpPxTpWQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-en-min/-/lang-en-min-4.26.1.tgz}
'@nlpjs/lang-en@4.26.1':
resolution: {integrity: sha512-GVoJpOjyk5TtBAqo/fxsiuuH7jXycyakGT0gw5f01u9lOmUnpJegvXyGff/Nb0j14pXcGHXOhmpWrcTrG2B0LQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-en/-/lang-en-4.26.1.tgz}
'@nlpjs/lang-es@4.26.1':
resolution: {integrity: sha512-fIPQt+WPcNdyxZOCMkOPlMb4Y1iE585QxjB9IAdFz8ZtVg7mc4dlv5f46ud7ppdMh84iLOuOdo6pzu2Cqm14lw==, tarball: https://registry.npmjs.org/@nlpjs/lang-es/-/lang-es-4.26.1.tgz}
'@nlpjs/lang-eu@4.26.1':
resolution: {integrity: sha512-Ha8GHTbgQYd7dwHM8aWHDyxmbUNUcyu/5xlBKqqBOPxysDyZ6Ad0tvj0FmJBy6mYhqmFTPBnEAo69cfuFSqWIQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-eu/-/lang-eu-4.26.1.tgz}
'@nlpjs/lang-fa@4.26.1':
resolution: {integrity: sha512-qJCmNXgJZnfNXUnKnxvEGEzSFBdQT4XU7/rMxuFmSJqmQY7fH/Vsmi5CKF94VRBPOIV4ULlEJuLpUWHXRmOnVQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-fa/-/lang-fa-4.26.1.tgz}
'@nlpjs/lang-fi@4.26.1':
resolution: {integrity: sha512-W/rUcrzSh3KE07q2vOsssTpU1sbX32gbBzKPZfRJ2ZUF4afO+eHxmAywikXubP4kiU3JxVNLvXXEjuGD3SBUbA==, tarball: https://registry.npmjs.org/@nlpjs/lang-fi/-/lang-fi-4.26.1.tgz}
'@nlpjs/lang-fr@4.26.1':
resolution: {integrity: sha512-LTA852atCJnHtKDmtjx/ui5AnvEIkrPx+MJQ2mB3gn8ko6i2UITnJgPmJE9Kej5bLasVZOAJvU/SrfXEmnPGOw==, tarball: https://registry.npmjs.org/@nlpjs/lang-fr/-/lang-fr-4.26.1.tgz}
'@nlpjs/lang-ga@4.26.1':
resolution: {integrity: sha512-JsP1CZ8r3Jd6o/Az7cN3exz0HDP3FNYLzh4Vi6ksEkdKF0yCjJ9G5dXZYqS9qFIN5ffemWn29G4WRELY6QH/cQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-ga/-/lang-ga-4.26.1.tgz}
'@nlpjs/lang-gl@4.26.1':
resolution: {integrity: sha512-y1NNu6NVy/6o5UNfihgg0WkSlVr4IvKA5W193CpRLZWS4FccQDmnFFhyYWRkshyDbgEsfsZ0Rs3BoE82+T2Ubg==, tarball: https://registry.npmjs.org/@nlpjs/lang-gl/-/lang-gl-4.26.1.tgz}
'@nlpjs/lang-hi@4.26.1':
resolution: {integrity: sha512-Fw9rXqF5l8q9etJG5uOlEFpnMVjQEWMaCIgQfEcA1yTvieSV8mpoSvQkEZl+DFhww+azareoJ7ZCkx0gJ9UDuQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-hi/-/lang-hi-4.26.1.tgz}
'@nlpjs/lang-hu@4.26.1':
resolution: {integrity: sha512-7dPUn5/ZpLZmsdRwO+dtORuMIiIpnsWbgSLIKdOLh8irhgUR+M2bYTfkdnKcrEcHzHPP8Svn7pU0xk7OKSUA1w==, tarball: https://registry.npmjs.org/@nlpjs/lang-hu/-/lang-hu-4.26.1.tgz}
'@nlpjs/lang-hy@4.26.1':
resolution: {integrity: sha512-T2brpLGDJryAwWmjtnmY8Ot6ZUkCz+/nRR9/QM1PybvZIqOVLjJqA49bqjJfT5DMN89HbwC7I/15NTT0y09i1Q==, tarball: https://registry.npmjs.org/@nlpjs/lang-hy/-/lang-hy-4.26.1.tgz}
'@nlpjs/lang-id@4.26.1':
resolution: {integrity: sha512-rVuIkYFKdltFhMT/a2ZxD9ovoZSVZF7OPuqYjTXW9xKd3Ff32yUrzcf/pHXlqmZOSltqOH3E5jZRRDkHvgUOjQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-id/-/lang-id-4.26.1.tgz}
'@nlpjs/lang-it@4.26.1':
resolution: {integrity: sha512-BZA3QnfQGW91gYaybRmHnCAPBvQggtmHZJrAmuBZUKUS12HoQm8uybjw2fZO+vahEeUQceKNDISRcT1eLLijog==, tarball: https://registry.npmjs.org/@nlpjs/lang-it/-/lang-it-4.26.1.tgz}
'@nlpjs/lang-ja@4.26.1':
resolution: {integrity: sha512-QgkuJOkHguRFyfnckH2It5/Kg8zecnOMJsHxYeuDC4tBF7jL/5xqWis+679lYLsXtAkrG8+fjVcBbjyopP0KHg==, tarball: https://registry.npmjs.org/@nlpjs/lang-ja/-/lang-ja-4.26.1.tgz}
'@nlpjs/lang-ko@4.26.1':
resolution: {integrity: sha512-Q0N8bLJJ829ILWCKH1UQWPSNyuLaEURAXCawkDju4pt33DBLcpqz9IzO9dnqiFc+fjSgVzZ7WMaLT18hXZQ9vg==, tarball: https://registry.npmjs.org/@nlpjs/lang-ko/-/lang-ko-4.26.1.tgz}
'@nlpjs/lang-lt@4.26.1':
resolution: {integrity: sha512-SeYZxRhdCy+ClQNnF/u0MAtcDui/ocdk4NtgNOCuwNTNuzhN3t3rfGeArfBGmZeg1SIeBLUDE9dsTxYCv5AOEg==, tarball: https://registry.npmjs.org/@nlpjs/lang-lt/-/lang-lt-4.26.1.tgz}
'@nlpjs/lang-ms@4.26.1':
resolution: {integrity: sha512-KxWBS+tFY2U8z9UrjQIqMM40npGDOskP5DcWhaEE3zuhzf3RTDYjy8sdz34jVd0fBdbPihX133h3bFibg2Cm7w==, tarball: https://registry.npmjs.org/@nlpjs/lang-ms/-/lang-ms-4.26.1.tgz}
'@nlpjs/lang-ne@4.26.1':
resolution: {integrity: sha512-K3E2l+0LTESv+dO+ZTIdvNa+zwMJvvnMiFYYkKvJst6lhc8JgvGOsPxGsjJn6PDhI3wyfQu+dg3b+bnVPu4FDA==, tarball: https://registry.npmjs.org/@nlpjs/lang-ne/-/lang-ne-4.26.1.tgz}
'@nlpjs/lang-nl@4.26.1':
resolution: {integrity: sha512-I/mP1RRbUN4BQ+8NXAl2FKaLHbb7f6S8JVjxHQ0sKHT4BgQ3+r0yO+DVcEsHg+vWRiY1Fyzh0gq0PhLVnF6HnA==, tarball: https://registry.npmjs.org/@nlpjs/lang-nl/-/lang-nl-4.26.1.tgz}
'@nlpjs/lang-no@4.26.1':
resolution: {integrity: sha512-a0CLL2c/OCzbg7J7ugyrsAksI96XhkQ3IeBbbx60o5o/9wsFNik6cPWrkpoE5xNtw7gLlAJWabwDiZXkl8Zrcw==, tarball: https://registry.npmjs.org/@nlpjs/lang-no/-/lang-no-4.26.1.tgz}
'@nlpjs/lang-pl@4.26.1':
resolution: {integrity: sha512-nrDXlq+TzQLE5IpXPIlFMzd8OpquvApWsouh6fmLsD9HZLZI4O3w1M4sXXLzE+9Ggu9Cy1m1QJ0/i7XCcv115g==, tarball: https://registry.npmjs.org/@nlpjs/lang-pl/-/lang-pl-4.26.1.tgz}
'@nlpjs/lang-pt@4.26.1':
resolution: {integrity: sha512-p6yZHaJ0e+n0avMHpdDw5PMk4HkKXjPbOMbrlg0dF+VRqChjxfH478Q423rDyzu/4MzDsIYB+p6KzL9AARKXpg==, tarball: https://registry.npmjs.org/@nlpjs/lang-pt/-/lang-pt-4.26.1.tgz}
'@nlpjs/lang-ro@4.26.1':
resolution: {integrity: sha512-baUdTA0DWpDR0Tn6fxo+RDN/6gbuINLCARtHwap2UR/HKQWP2XoH/DIvcjZpwUTalr5MQjso31epcdeRRapczA==, tarball: https://registry.npmjs.org/@nlpjs/lang-ro/-/lang-ro-4.26.1.tgz}
'@nlpjs/lang-ru@4.26.1':
resolution: {integrity: sha512-NaZ2DAOGxWG2Us9IyIDs3m6vhGpUaUJRVgzzHHyX3LO3xEYjZmtnA0jEpBaTOe2PuNHThv0WCZUNn9BSurV3PA==, tarball: https://registry.npmjs.org/@nlpjs/lang-ru/-/lang-ru-4.26.1.tgz}
'@nlpjs/lang-sl@4.26.1':
resolution: {integrity: sha512-QBJwcJt+oKUpAnHKNJkLkx9Xm1n4dUPC5GPYfAXTnJZf0hNWJSY21GicdWi7Vu/qFJ3ghIqtSP8D7KIPLnibNw==, tarball: https://registry.npmjs.org/@nlpjs/lang-sl/-/lang-sl-4.26.1.tgz}
'@nlpjs/lang-sr@4.26.1':
resolution: {integrity: sha512-drH3+UqTW637uLWsnLrcp8jEKUGxV61ZgCBjNkVQNEv1/jbpSg6IqgynSY2JyhtnlV0f870KS0HvSbyo5AD4Ng==, tarball: https://registry.npmjs.org/@nlpjs/lang-sr/-/lang-sr-4.26.1.tgz}
'@nlpjs/lang-sv@4.26.1':
resolution: {integrity: sha512-2axkrYFC02tAlxCWeiEKISbe4dSteciP1CIggO/dZglnnLWgdF+g7kOeYMn7abCfFVSnh5vLqfDkrwnyIqt7Ag==, tarball: https://registry.npmjs.org/@nlpjs/lang-sv/-/lang-sv-4.26.1.tgz}
'@nlpjs/lang-ta@4.26.1':
resolution: {integrity: sha512-keeh+croa1TAirV9Fd3OQMo5IkAlTGNWTNweHbi/htYMX0MKOPYxyqg+VH2bml+57VY2aUj/WYgV/p3ATx9EfQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-ta/-/lang-ta-4.26.1.tgz}
'@nlpjs/lang-th@4.26.1':
resolution: {integrity: sha512-2SWZhrln3rMw8/DsRc9yS5bi3qEdGfw2pq9Uejx/UYED5zvvL6kh9AiCJZT4k0wMBGEwWUV6HxJ0Pq/jOTHogg==, tarball: https://registry.npmjs.org/@nlpjs/lang-th/-/lang-th-4.26.1.tgz}
'@nlpjs/lang-tl@4.26.1':
resolution: {integrity: sha512-AzmLtg28tm0VXCm0Q0EY3OtA3m4oYxaqh4VX6uhB4J+PoEsIkm0py12SJxMNIsh/r98pobCumH8KH9bvHQoCAg==, tarball: https://registry.npmjs.org/@nlpjs/lang-tl/-/lang-tl-4.26.1.tgz}
'@nlpjs/lang-tr@4.26.1':
resolution: {integrity: sha512-p30uuXvE9pZeU/5XkrQfvxRgiAOBmP3EyBFGV/+P05PEogaqbsmmtVCgCnR63yeRvVnGbToPBPjRK3OO1y4AEQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-tr/-/lang-tr-4.26.1.tgz}
'@nlpjs/lang-uk@4.26.1':
resolution: {integrity: sha512-PVEvmlhvl6BL3e/Q4qjMPsnwON3cWEYvDh9dg+Si+sjD2Edu9tajolJKcQ6ZA4I8dXrld5xuXx+DEBH/uB4uWQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-uk/-/lang-uk-4.26.1.tgz}
'@nlpjs/lang-zh@4.26.1':
resolution: {integrity: sha512-kwqeqeEgMAMvucVX9HNE1p6s/2APP23ZsS8Um/lNvtswb4gL5jjYF9kyCvRfqlPBQSWWdRv7wwcnNXOvXYkxcQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-zh/-/lang-zh-4.26.1.tgz}
'@nlpjs/language-min@4.25.0':
resolution: {integrity: sha512-g8jtbDbqtRm+dlD/1Vnb4VWfKbKteApEGVTqIMxYkk6N/HMhvLZ5J2svrxzrB98a/HZ0fb//YBfFgymnz9Oukg==, tarball: https://registry.npmjs.org/@nlpjs/language-min/-/language-min-4.25.0.tgz}
'@nlpjs/language@4.25.0':
resolution: {integrity: sha512-tUF6QENoUQ/E26RYc32IgsttStSF9cNO4ySN+BQECn8VpjukWdwbMw073MlOLXzjfeobxa+3hCVrmPPcW+V3UA==, tarball: https://registry.npmjs.org/@nlpjs/language/-/language-4.25.0.tgz}
'@nlpjs/ner@4.27.0':
resolution: {integrity: sha512-ptwkxriJdmgHSH9TfP10JQ1jviaSl2SupSFGUvTuWkuJhobQd3hbnlSq40V6XYvJNmqh9M9zEab/AKeghxYOTA==, tarball: https://registry.npmjs.org/@nlpjs/ner/-/ner-4.27.0.tgz}
'@nlpjs/neural@4.25.0':
resolution: {integrity: sha512-Oz20denGiBe0DlQsS7lN4TNrATN1nXlHKc/HB6jJPegjVmgJVCugDaHwIGoV7qOWyA6F2fRRwOgD+quNT2gVpg==, tarball: https://registry.npmjs.org/@nlpjs/neural/-/neural-4.25.0.tgz}
'@nlpjs/nlg@4.26.1':
resolution: {integrity: sha512-PCJWiZ7464ChXXUGvjBZIFtoqkC24Oy6X63HgQrSv+63svz22Y5Cmu1MYLk77Nb+4keWv+hKhFJKDkvJoOpBVg==, tarball: https://registry.npmjs.org/@nlpjs/nlg/-/nlg-4.26.1.tgz}
'@nlpjs/nlp@4.27.0':
resolution: {integrity: sha512-q6X7sY6TYVnQRZJKF/6mfLFlNA5oRYLhgQ5k3i1IBqH9lbWTAZJr31w/dCf97HXaYaj+vJp3h0ucfNumme9EIw==, tarball: https://registry.npmjs.org/@nlpjs/nlp/-/nlp-4.27.0.tgz}
'@nlpjs/nlu@4.27.0':
resolution: {integrity: sha512-j4DUdoXS/y/Xag6ysYXx7Ve8NBmUVViUSCJhj3r49+zGyYtyVAHuVcqSej5q0tJjn0JSMT+6+ip8klON1q8ixw==, tarball: https://registry.npmjs.org/@nlpjs/nlu/-/nlu-4.27.0.tgz}
'@nlpjs/request@4.25.0':
resolution: {integrity: sha512-MPVYWfFZY03WyFL7GWkUkv8tw968OXsdxFSJEvjXHzhiCe/vAlPCWbvoR+VnoQTgzLHxs/KIF6sIF2s9AzsLmQ==, tarball: https://registry.npmjs.org/@nlpjs/request/-/request-4.25.0.tgz}
'@nlpjs/sentiment@4.26.1':
resolution: {integrity: sha512-U2WmcW3w6yDDO45+Y7v5e6DPQj8e0x+RUUePPyRu2uIZmUtIKG+qCPMWnNLMmYQZoSQEFxmMMlLcGDC7tN7o3w==, tarball: https://registry.npmjs.org/@nlpjs/sentiment/-/sentiment-4.26.1.tgz}
'@nlpjs/similarity@4.26.1':
resolution: {integrity: sha512-QutSBFGo/huNuz60PgqCjub0oBd9S8MLrjme33U5GzxuSvToQzXtn9/ynIia8qDm009D09VXV+LPeNE4h7yuSg==, tarball: https://registry.npmjs.org/@nlpjs/similarity/-/similarity-4.26.1.tgz}
'@nlpjs/slot@4.26.1':
resolution: {integrity: sha512-mK8EEy5O+mRGne822PIKMxHSFh8j+iC7hGJ6T31XdFsNhFEYXLI/0dmeBstZgTSKBTe27HNFgCCwuGb77u0o9w==, tarball: https://registry.npmjs.org/@nlpjs/slot/-/slot-4.26.1.tgz}
'@nlpjs/xtables@4.25.0':
resolution: {integrity: sha512-+baCtMZIp+aDqODLQs8Wyyke5qUqQkL8AGWsZzwYuJV8S7xdW2+XklRnHnkFc3p3foC248TkzG5L8j9r6INOtg==, tarball: https://registry.npmjs.org/@nlpjs/xtables/-/xtables-4.25.0.tgz}
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==, tarball: https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz}
engines: {node: ^14.21.3 || >=16}
@ -1118,6 +1333,10 @@ packages:
resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==, tarball: https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-3.0.0.tgz}
engines: {node: '>=14'}
'@tootallnate/once@2.0.1':
resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==, tarball: https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz}
engines: {node: '>= 10'}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==, tarball: https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz}
@ -1287,6 +1506,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==, tarball: https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz}
engines: {node: '>=0.8'}
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==, tarball: https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz}
engines: {node: '>= 6.0.0'}
@ -1390,6 +1613,9 @@ packages:
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==, tarball: https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz}
engines: {node: '>=8'}
async@2.6.4:
resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==, tarball: https://registry.npmjs.org/async/-/async-2.6.4.tgz}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==, tarball: https://registry.npmjs.org/async/-/async-3.2.6.tgz}
@ -1431,6 +1657,9 @@ packages:
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==, tarball: https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz}
bignumber.js@7.2.1:
resolution: {integrity: sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ==, tarball: https://registry.npmjs.org/bignumber.js/-/bignumber.js-7.2.1.tgz}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==, tarball: https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz}
engines: {node: '>=8'}
@ -1519,6 +1748,10 @@ packages:
caseless@0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==, tarball: https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==, tarball: https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz}
engines: {node: '>=0.8'}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==, tarball: https://registry.npmjs.org/chai/-/chai-5.3.3.tgz}
engines: {node: '>=18'}
@ -1589,6 +1822,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==, tarball: https://registry.npmjs.org/clone/-/clone-1.0.4.tgz}
engines: {node: '>=0.8'}
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==, tarball: https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz}
engines: {node: '>=0.8'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, tarball: https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz}
engines: {node: '>=7.0.0'}
@ -1703,6 +1940,11 @@ packages:
typescript:
optional: true
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==, tarball: https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz}
engines: {node: '>=0.8'}
hasBin: true
cross-env@10.1.0:
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==, tarball: https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz}
engines: {node: '>=20'}
@ -1890,6 +2132,9 @@ packages:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==, tarball: https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz}
engines: {node: '>=12'}
doublearray@0.0.2:
resolution: {integrity: sha512-aw55FtZzT6AmiamEj2kvmR6BuFqvYgKZUkfQ7teqVRNqD5UE0rw8IeW/3gieHNKQ5sPuDKlljWEn4bzv5+1bHw==, tarball: https://registry.npmjs.org/doublearray/-/doublearray-0.0.2.tgz}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, tarball: https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz}
engines: {node: '>= 0.4'}
@ -2156,6 +2401,10 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==, tarball: https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz}
engines: {node: '>= 0.6'}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==, tarball: https://registry.npmjs.org/frac/-/frac-1.1.2.tgz}
engines: {node: '>=0.8'}
fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==, tarball: https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz}
engines: {node: '>= 0.6'}
@ -2261,6 +2510,9 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, tarball: https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz}
grapheme-splitter@1.0.4:
resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==, tarball: https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz}
has-ansi@4.0.1:
resolution: {integrity: sha512-Qr4RtTm30xvEdqUXbSBVWDu+PrTokJOwe/FU+VdfJPk+MXAPoeOzKpRyrDTnZIJwAkQ4oBLTU53nu0HrkF/Z2A==, tarball: https://registry.npmjs.org/has-ansi/-/has-ansi-4.0.1.tgz}
engines: {node: '>=8'}
@ -2306,6 +2558,10 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz}
engines: {node: '>= 0.8'}
http-proxy-agent@5.0.0:
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==, tarball: https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz}
engines: {node: '>= 6'}
http-signature@1.4.0:
resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==, tarball: https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz}
engines: {node: '>=0.10'}
@ -2558,6 +2814,9 @@ packages:
knuth-shuffle-seeded@1.0.6:
resolution: {integrity: sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==, tarball: https://registry.npmjs.org/knuth-shuffle-seeded/-/knuth-shuffle-seeded-1.0.6.tgz}
kuromoji@0.1.2:
resolution: {integrity: sha512-V0dUf+C2LpcPEXhoHLMAop/bOht16Dyr+mDiIE39yX3vqau7p80De/koFqpiTcL1zzdZlc3xuHZ8u5gjYRfFaQ==, tarball: https://registry.npmjs.org/kuromoji/-/kuromoji-0.1.2.tgz}
lazy-ass@1.6.0:
resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==, tarball: https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz}
engines: {node: '> 0.8'}
@ -2822,6 +3081,9 @@ packages:
node-html-parser@5.3.3:
resolution: {integrity: sha512-ncg1033CaX9UexbyA7e1N0aAoAYRDiV8jkTvzEnfd1GDvzFdrsXLzR4p4ik8mwLgnaKP/jyUFWDy9q3jvRT2Jw==, tarball: https://registry.npmjs.org/node-html-parser/-/node-html-parser-5.3.3.tgz}
node-nlp@4.27.0:
resolution: {integrity: sha512-LnkhOUPXX0CMFbSzJ1gHI+7Yb3ULLip5gRsqedXb6pryjcRCbNzPgHXcH/6G9B1vSbDfO+y3X2B4QZpfP12OyQ==, tarball: https://registry.npmjs.org/node-nlp/-/node-nlp-4.27.0.tgz}
node-releases@2.0.53:
resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==, tarball: https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz}
engines: {node: '>=18'}
@ -3371,6 +3633,10 @@ packages:
split@1.0.1:
resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==, tarball: https://registry.npmjs.org/split/-/split-1.0.1.tgz}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==, tarball: https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz}
engines: {node: '>=0.8'}
sshpk@1.18.0:
resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==, tarball: https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz}
engines: {node: '>=0.10.0'}
@ -3711,6 +3977,14 @@ packages:
wide-align@1.1.5:
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==, tarball: https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz}
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==, tarball: https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz}
engines: {node: '>=0.8'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==, tarball: https://registry.npmjs.org/word/-/word-0.3.0.tgz}
engines: {node: '>=0.8'}
workerpool@6.5.1:
resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==, tarball: https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz}
@ -3732,6 +4006,11 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, tarball: https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz}
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==, tarball: https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz}
engines: {node: '>=0.8'}
hasBin: true
xmlbuilder@15.1.1:
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==, tarball: https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz}
engines: {node: '>=8.0'}
@ -3785,6 +4064,9 @@ packages:
yup@1.6.1:
resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==, tarball: https://registry.npmjs.org/yup/-/yup-1.6.1.tgz}
zlibjs@0.3.1:
resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==, tarball: https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, tarball: https://registry.npmjs.org/zod/-/zod-3.25.76.tgz}
@ -4393,9 +4675,344 @@ snapshots:
- encoding
- supports-color
'@microsoft/recognizers-text-choice@1.3.1':
dependencies:
'@microsoft/recognizers-text': 1.3.1
grapheme-splitter: 1.0.4
'@microsoft/recognizers-text-data-types-timex-expression@1.3.1': {}
'@microsoft/recognizers-text-date-time@1.3.2':
dependencies:
'@microsoft/recognizers-text': 1.3.1
'@microsoft/recognizers-text-number': 1.3.1
'@microsoft/recognizers-text-number-with-unit': 1.3.1
lodash: 4.18.1
'@microsoft/recognizers-text-number-with-unit@1.3.1':
dependencies:
'@microsoft/recognizers-text': 1.3.1
'@microsoft/recognizers-text-number': 1.3.1
lodash: 4.18.1
'@microsoft/recognizers-text-number@1.3.1':
dependencies:
'@microsoft/recognizers-text': 1.3.1
bignumber.js: 7.2.1
lodash: 4.18.1
'@microsoft/recognizers-text-sequence@1.3.1':
dependencies:
'@microsoft/recognizers-text': 1.3.1
grapheme-splitter: 1.0.4
'@microsoft/recognizers-text-suite@1.3.0':
dependencies:
'@microsoft/recognizers-text': 1.3.1
'@microsoft/recognizers-text-choice': 1.3.1
'@microsoft/recognizers-text-data-types-timex-expression': 1.3.1
'@microsoft/recognizers-text-date-time': 1.3.2
'@microsoft/recognizers-text-number': 1.3.1
'@microsoft/recognizers-text-number-with-unit': 1.3.1
'@microsoft/recognizers-text-sequence': 1.3.1
'@microsoft/recognizers-text@1.3.1': {}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
optional: true
'@nlpjs/builtin-duckling@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/builtin-microsoft@4.26.1':
dependencies:
'@microsoft/recognizers-text-suite': 1.3.0
'@nlpjs/core': 4.26.1
'@nlpjs/core-loader@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/request': 4.25.0
transitivePeerDependencies:
- supports-color
'@nlpjs/core@4.26.1': {}
'@nlpjs/emoji@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/evaluator@4.26.1':
dependencies:
escodegen: 2.1.0
esprima: 4.0.1
'@nlpjs/lang-all@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-ar': 4.26.1
'@nlpjs/lang-bn': 4.26.1
'@nlpjs/lang-ca': 4.26.1
'@nlpjs/lang-cs': 4.26.1
'@nlpjs/lang-da': 4.26.1
'@nlpjs/lang-de': 4.26.1
'@nlpjs/lang-el': 4.26.1
'@nlpjs/lang-en': 4.26.1
'@nlpjs/lang-es': 4.26.1
'@nlpjs/lang-eu': 4.26.1
'@nlpjs/lang-fa': 4.26.1
'@nlpjs/lang-fi': 4.26.1
'@nlpjs/lang-fr': 4.26.1
'@nlpjs/lang-ga': 4.26.1
'@nlpjs/lang-gl': 4.26.1
'@nlpjs/lang-hi': 4.26.1
'@nlpjs/lang-hu': 4.26.1
'@nlpjs/lang-hy': 4.26.1
'@nlpjs/lang-id': 4.26.1
'@nlpjs/lang-it': 4.26.1
'@nlpjs/lang-ja': 4.26.1
'@nlpjs/lang-ko': 4.26.1
'@nlpjs/lang-lt': 4.26.1
'@nlpjs/lang-ms': 4.26.1
'@nlpjs/lang-ne': 4.26.1
'@nlpjs/lang-nl': 4.26.1
'@nlpjs/lang-no': 4.26.1
'@nlpjs/lang-pl': 4.26.1
'@nlpjs/lang-pt': 4.26.1
'@nlpjs/lang-ro': 4.26.1
'@nlpjs/lang-ru': 4.26.1
'@nlpjs/lang-sl': 4.26.1
'@nlpjs/lang-sr': 4.26.1
'@nlpjs/lang-sv': 4.26.1
'@nlpjs/lang-ta': 4.26.1
'@nlpjs/lang-th': 4.26.1
'@nlpjs/lang-tl': 4.26.1
'@nlpjs/lang-tr': 4.26.1
'@nlpjs/lang-uk': 4.26.1
'@nlpjs/lang-zh': 4.26.1
'@nlpjs/language': 4.25.0
'@nlpjs/lang-ar@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-bn@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-ca@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-cs@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-da@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-de@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-el@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-en-min@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-en@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-en-min': 4.26.1
'@nlpjs/lang-es@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-eu@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-fa@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-fi@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-fr@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-ga@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-gl@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-hi@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-hu@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-hy@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-id@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-it@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-ja@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
kuromoji: 0.1.2
'@nlpjs/lang-ko@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-lt@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-ms@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-id': 4.26.1
'@nlpjs/lang-ne@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-nl@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-no@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-pl@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-pt@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-ro@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-ru@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-sl@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-sr@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-sv@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-ta@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-th@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-tl@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-tr@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-uk@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/lang-zh@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/language-min@4.25.0': {}
'@nlpjs/language@4.25.0': {}
'@nlpjs/ner@4.27.0':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/language-min': 4.25.0
'@nlpjs/similarity': 4.26.1
'@nlpjs/neural@4.25.0': {}
'@nlpjs/nlg@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/nlp@4.27.0':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/ner': 4.27.0
'@nlpjs/nlg': 4.26.1
'@nlpjs/nlu': 4.27.0
'@nlpjs/sentiment': 4.26.1
'@nlpjs/slot': 4.26.1
'@nlpjs/nlu@4.27.0':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/language-min': 4.25.0
'@nlpjs/neural': 4.25.0
'@nlpjs/similarity': 4.26.1
'@nlpjs/request@4.25.0':
dependencies:
http-proxy-agent: 5.0.0
https-proxy-agent: 5.0.1
transitivePeerDependencies:
- supports-color
'@nlpjs/sentiment@4.26.1':
dependencies:
'@nlpjs/core': 4.26.1
'@nlpjs/language-min': 4.25.0
'@nlpjs/neural': 4.25.0
'@nlpjs/similarity@4.26.1': {}
'@nlpjs/slot@4.26.1': {}
'@nlpjs/xtables@4.25.0':
dependencies:
xlsx: 0.18.5
'@noble/hashes@1.8.0': {}
'@nodelib/fs.scandir@2.1.5':
@ -4582,6 +5199,8 @@ snapshots:
'@teppeis/multimaps@3.0.0': {}
'@tootallnate/once@2.0.1': {}
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.8
@ -4804,6 +5423,8 @@ snapshots:
acorn@8.18.0: {}
adler-32@1.3.1: {}
agent-base@6.0.2:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
@ -4893,6 +5514,10 @@ snapshots:
astral-regex@2.0.0: {}
async@2.6.4:
dependencies:
lodash: 4.18.1
async@3.2.6: {}
asynckit@0.4.0: {}
@ -4929,6 +5554,8 @@ snapshots:
dependencies:
tweetnacl: 0.14.5
bignumber.js@7.2.1: {}
binary-extensions@2.3.0: {}
blob-util@2.0.2: {}
@ -5027,6 +5654,11 @@ snapshots:
caseless@0.12.0: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@ -5106,6 +5738,8 @@ snapshots:
clone@1.0.4:
optional: true
codepage@1.15.0: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@ -5187,6 +5821,8 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
crc-32@1.2.2: {}
cross-env@10.1.0:
dependencies:
'@epic-web/invariant': 1.0.0
@ -5431,6 +6067,8 @@ snapshots:
dotenv@16.6.1: {}
doublearray@0.0.2: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@ -5823,6 +6461,8 @@ snapshots:
forwarded@0.2.0: {}
frac@1.1.2: {}
fresh@0.5.2: {}
from@0.1.7: {}
@ -5944,6 +6584,8 @@ snapshots:
graceful-fs@4.2.11: {}
grapheme-splitter@1.0.4: {}
has-ansi@4.0.1:
dependencies:
ansi-regex: 4.1.1
@ -5984,6 +6626,14 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
http-proxy-agent@5.0.0:
dependencies:
'@tootallnate/once': 2.0.1
agent-base: 6.0.2
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
http-signature@1.4.0:
dependencies:
assert-plus: 1.0.0
@ -6226,6 +6876,12 @@ snapshots:
dependencies:
seed-random: 2.2.0
kuromoji@0.1.2:
dependencies:
async: 2.6.4
doublearray: 0.0.2
zlibjs: 0.3.1
lazy-ass@1.6.0: {}
lazy-ass@2.0.3: {}
@ -6477,6 +7133,26 @@ snapshots:
css-select: 4.3.0
he: 1.2.0
node-nlp@4.27.0:
dependencies:
'@nlpjs/builtin-duckling': 4.26.1
'@nlpjs/builtin-microsoft': 4.26.1
'@nlpjs/core-loader': 4.26.1
'@nlpjs/emoji': 4.26.1
'@nlpjs/evaluator': 4.26.1
'@nlpjs/lang-all': 4.26.1
'@nlpjs/language': 4.25.0
'@nlpjs/neural': 4.25.0
'@nlpjs/nlg': 4.26.1
'@nlpjs/nlp': 4.27.0
'@nlpjs/nlu': 4.27.0
'@nlpjs/request': 4.25.0
'@nlpjs/sentiment': 4.26.1
'@nlpjs/similarity': 4.26.1
'@nlpjs/xtables': 4.25.0
transitivePeerDependencies:
- supports-color
node-releases@2.0.53: {}
node-source-walk@7.0.2:
@ -7075,6 +7751,10 @@ snapshots:
dependencies:
through: 2.3.8
ssf@0.11.2:
dependencies:
frac: 1.1.2
sshpk@1.18.0:
dependencies:
asn1: 0.2.6
@ -7399,6 +8079,10 @@ snapshots:
dependencies:
string-width: 4.2.3
wmf@1.0.2: {}
word@0.3.0: {}
workerpool@6.5.1: {}
workerpool@9.3.4: {}
@ -7423,6 +8107,16 @@ snapshots:
wrappy@1.0.2: {}
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
xmlbuilder@15.1.1: {}
y18n@5.0.8: {}
@ -7480,4 +8174,6 @@ snapshots:
toposort: 2.0.2
type-fest: 2.19.0
zlibjs@0.3.1: {}
zod@3.25.76: {}

View file

@ -1,11 +0,0 @@
# Secret partagé attendu sur le header `X-Intent-Service-Secret` de chaque
# requête (sauf `GET /health`) — doit matcher `INTENT_SERVICE_SECRET` côté
# apps/api/.env (voir apps/api/src/config/env.ts). Requis, pas de valeur par
# défaut : `Settings` (intent_service/config.py) refuse de démarrer sans.
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optionnel — niveau du logging JSON structuré (intent_service/logging_config.py).
# INFO par défaut : chaque appel /v1/process et /v1/train journalise son
# input (locale/texte, entrées d'entraînement) et son output (entités,
# intent, score) à ce niveau.
# LOG_LEVEL=INFO

View file

@ -1,5 +0,0 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
.env

View file

@ -1,34 +0,0 @@
# Standalone image for services/tech-step-intent-service — hors du build
# apps/api (voir services/tech-step-llm-worker/Dockerfile pour le précédent
# direct : un service Python/spaCy n'a rien à faire dans l'image Node de
# l'API, et inversement). Rien n'est persisté sur disque (pas de VOLUME,
# contrairement au worker LLM) : tout l'état (textcat/matcher entraînés)
# vit en mémoire, reconstruit à chaque `/v1/train` depuis un corpus que ce
# service ne possède pas lui-même (voir intent_service/README.md).
FROM python:3.12-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /service
FROM base AS build
# `uv.lock` est commité pour ce service (même rigueur que
# `pnpm-lock.yaml`/`--frozen-lockfile` pour apps/api et
# services/tech-step-llm-worker) — `--frozen` échoue bruyamment si
# `pyproject.toml` a dérivé du lock plutôt que de re-résoudre en silence.
# `--no-install-project` sépare l'installation des dépendances (dont les
# wheels de modèles spaCy, pinnés par URL dans pyproject.toml) de la copie
# du code applicatif, pour que le cache de layer Docker survive à un
# changement dans intent_service/ sans retélécharger ~80 Mo de modèles.
# Chemins préfixés par `services/tech-step-intent-service/` : le contexte
# de build est la racine du repo (`docker-compose.yml`'s `build.context: .`),
# même convention que `services/tech-step-llm-worker/Dockerfile`.
COPY services/tech-step-intent-service/pyproject.toml services/tech-step-intent-service/uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
COPY services/tech-step-intent-service/intent_service ./intent_service
RUN uv sync --frozen --no-dev
FROM base AS runtime
ENV PYTHONUNBUFFERED=1
COPY --from=build /service /service
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "intent_service.main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -1,198 +0,0 @@
# tech-step-intent-service
Microservice de détection d'intention (technique de cuisine) — remplace le
pipeline `node-nlp` qui vivait dans `apps/api`
(`TechStepClassifierService`, `apps/api/src/lib/recipe-matching/tech-step-matcher.ts`) :
1. **NER par phrases** (`spacy.matcher.PhraseMatcher`) — trouve les mentions
candidates d'une technique dans un texte, à partir des `synonyms` de
chaque technique.
2. **Classification d'intention** (`textcat` spaCy, bag-of-words) — verdict
de la technique qu'une clause de texte *signifie*, entraîné sur les
`utterances` de chaque technique (y compris des paraphrases n'utilisant
jamais le mot-clé lui-même).
3. **NER par phrases, ustensiles** (`spacy.matcher.PhraseMatcher`, second
matcher indépendant) — trouve les mentions d'un ustensile de cuisine
(`intent_service/utensil_vocabulary.py`, `UTENSIL_VOCABULARY`), sans
`textcat` associé : contrairement à une technique, un ustensile mentionné
n'a pas besoin d'être interprété selon le contexte. Renvoyé dans la même
liste `entities` que les techniques, discriminé par `kind`.
Basé sur **spaCy** (`fr_core_news_md`/`en_core_web_md`) plutôt que node-nlp —
écosystème NLP plus robuste/maintenu, avec l'ambition à terme (hors scope de
ce service en l'état) de pouvoir aussi absorber ce que fait aujourd'hui
`services/tech-step-llm-worker` une fois ce pipeline assez riche pour s'en
passer (les modèles `md`, avec vecteurs de mots, sont conservés dans ce but,
même si rien ici ne s'en sert encore).
## Ce service est entièrement autonome
Contrairement à sa toute première version, **ce service possède désormais
son propre corpus** — `intent_service/training_data.py`
(`TECH_STEP_TRAINING_DATA`), revu par PR comme le reste du code. Il
s'entraîne lui-même une seule fois, à son propre démarrage
(`PipelineRegistry.initialize()`, appelé par `main.py`'s `lifespan`), et ne
persiste jamais rien sur disque — un redémarrage du process réentraîne
toujours from scratch depuis ce fichier. `apps/api` ne connaît plus aucune
technique ni aucun synonyme : il n'appelle plus que `POST /v1/process` (plus
de `POST /v1/train`, supprimé).
Workflow mainteneur pour changer le corpus :
1. Éditer `intent_service/training_data.py` à la main (informé par le
rapport de `apps/api/src/scripts/list-pending-training-suggestions.ts`)
pour une technique, ou `intent_service/utensil_vocabulary.py` pour un
ustensile (pas de rapport équivalent pour ce dernier — pas de mécanisme
de correction utilisateur sur les ustensiles aujourd'hui). Chaque
technique doit garder le même nombre d'`utterances` que les autres, par
locale (voir `training_data.py`'s own doc comment) — une technique
ajoutée avec moins que le max courant, exécuter `augment_utterances.py`
(racine de ce service) pour rééquilibrer, puis **impérativement**
relancer l'étape 3 ci-dessous avant de committer : chaque tentative
passée d'élargir ce corpus (voir l'historique Git de
`training_data.py`) a dû être ajustée ou annulée après coup faute
d'avoir vérifié le F1 avant de pousser.
2. **Redémarrer ce service** (`docker compose restart tech-step-intent-service`,
ou simplement redéployer) — le nouveau corpus n'a d'effet qu'une fois
réentraîné au démarrage, contrairement à l'ancienne version qui pouvait
être réentraînée à chaud via `POST /v1/train`.
3. Depuis `apps/api`, lancer `pnpm --filter api exec tsx
src/scripts/retrain-tech-steps.ts` — vérifie le F1 contre
`TECH_STEP_EVAL_DATASET` avant de backfiller les recettes existantes.
## Pourquoi ce service vit hors du workspace pnpm
Même raisonnement que `services/tech-step-llm-worker` : un service Python
n'a rien à faire dans `pnpm-workspace.yaml` (qui ne couvre que
`apps/*`/`packages/*`), et ses dépendances (spaCy, ses modèles) ne doivent
jamais se retrouver dans l'image `apps/api`. **Aucun accès direct à
Postgres** non plus — la résolution `TechStep.key -> id` reste entièrement
côté `apps/api` (`TechStepClassifierService`), ce service ne manipule que
des `uid` (chaînes opaques) tout du long.
## Contrat HTTP
Voir `intent_service/schemas.py` pour le détail exact. En résumé :
- `GET /health` — sans authentification, `200` une fois ce service
entièrement prêt : modèles spaCy de base chargés **et** les deux locales
entraînées (pas de lazy-load, voir `intent_service/main.py`) — voir
"Temps de démarrage" plus bas pour ce que ça implique en pratique.
- `POST /v1/process``{ locale, text }``{ entities: [{ uid, start, end, kind }], intent, score }`,
`kind` valant `"technique"` ou `"utensil"` selon le `PhraseMatcher` qui a
trouvé la mention (voir point 3 ci-dessus). `apps/api`'s `tech-step-matcher.ts`
filtre par `kind` pour savoir laquelle des deux résoudre (`TechStep`/`Utensil`).
`/v1/process` exige le header `X-Intent-Service-Secret` (voir
`intent_service/security.py`), qui doit matcher `INTENT_SERVICE_SECRET`
côté `apps/api`.
## Temps de démarrage
**Ce service met plusieurs minutes à devenir `healthy`** — contrairement à
node-nlp (entraînement quasi instantané), entraîner le `textcat` sur le
corpus réel (~74 techniques, chaque technique entraînée sur ses `synonyms`
en plus de ses `utterances` — voir `locale_pipeline.py`) prend de l'ordre
de 540 secondes pour `fr` / 390 secondes pour `en` (mesuré localement,
sans GPU), donc environ 930 secondes (~15-16 minutes) pour `fr`+`en`
combinés à chaque démarrage du process — chaque technique a désormais le
même nombre d'`utterances` par locale (voir `training_data.py`'s own doc
comment), légèrement plus qu'avant ce rééquilibrage. `docker-compose.yml`
et `.github/workflows/ci.yml` ont un `start_period`/timeout d'attente
généreux pour ça (`1200s`) — voir leurs propres commentaires. C'est un
compromis
assumé, pas un défaut de configuration à corriger : moins d'itérations
entraîne plus vite mais laisse des verdicts corrects sous
`CONFIDENCE_THRESHOLD` (voir le commentaire de cette constante,
`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`, et celui de
`_TRAINING_ITERATIONS`/`_TRAINING_BATCH_SIZE` dans `locale_pipeline.py`
pour le détail du compromis).
## Logs
`intent_service/logging_config.py` branche un format JSON structuré (une
ligne par évènement — `timestamp`/`level`/`message` + champs métier fusionnés
— même convention que `LoggerService` côté `apps/api`) sur toute la
journalisation de ce service, niveau `LOG_LEVEL` (`INFO` par défaut, voir
`.env.example`). `routes/process.py` journalise chaque appel avec son input
et son output complets, `pipeline_registry.py` journalise le déroulement de
l'entraînement au démarrage :
```json
{"timestamp": "...", "level": "info", "message": "tech-step NLP process", "locale": "fr", "text": "faire fondre le beurre", "entities": [{"uid": "melt", "start": 6, "end": 13, "kind": "technique"}], "intent": "melt", "score": 0.93}
```
Le chatter interne de spaCy (`"spacy"` logger — chargement de vocabulaire,
etc.) est explicitement mis à `WARNING` pour ne pas noyer ces lignes.
## Setup
Ce service utilise [`uv`](https://docs.astral.sh/uv/) pour ses dépendances
(`uv.lock` committé, `uv sync --frozen` partout — Dockerfile, CI, dev).
```bash
cd services/tech-step-intent-service
uv sync
cp .env.example .env
# édite .env : génère un INTENT_SERVICE_SECRET, identique à celui d'apps/api
uv run uvicorn intent_service.main:app --reload --port 8000
```
`apps/api` (natif, `pnpm dev:api`, ou sa suite Mocha) doit pointer
`INTENT_SERVICE_BASE_URL=http://localhost:8000` et le même
`INTENT_SERVICE_SECRET` (voir `apps/api/.env.example`).
## Running via Docker Compose
`docker-compose.yml` (racine) définit un service `tech-step-intent-service`
aux côtés de `postgres`/`app`/`tech-step-llm-worker` — **pas optionnel**,
contrairement au worker LLM : sans lui, `apps/api` ne peut plus détecter
aucune technique de cuisine. `app` attend qu'il soit `healthy`
(`depends_on: condition: service_healthy`) avant de démarrer — voir "Temps
de démarrage" ci-dessus pour combien de temps ça prend en pratique.
## Testing
```bash
uv run pytest
```
`tests/test_locale_pipeline_entities.py` rejoue les cas d'offsets caractère
exacts et d'insensibilité accents/casse de
`apps/api/test/recipe-matching/tech-step-matcher.test.ts` — le point de
fidélité le plus critique de ce service (voir le plan de migration).
`tests/test_utensil_matching.py` couvre le second `PhraseMatcher`
(ustensiles) de la même façon, contre le vocabulaire réel (statique, pas
besoin d'un jeu de test dédié comme pour les techniques).
`tests/conftest.py`'s fixture `client` (scope "session") ne s'entraîne
qu'une seule fois pour toute la suite — c'est *le vrai corpus complet*,
pas un jeu jouet, donc la première utilisation de cette fixture prend le
même temps qu'un vrai démarrage (voir "Temps de démarrage" ci-dessus).
Aucun test ici ne dépend d'une vraie base Postgres ni d'`apps/api` en
service — à l'inverse, la suite Mocha d'`apps/api`
(`tech-step-matcher.test.ts`/`recipe-translation.test.ts`) exige elle une
vraie instance de ce service tournant (voir `apps/api/.env.test`), conforme
à la convention du repo de ne jamais mocker un service interne.
## Limitations connues
- **Démarrage lent** (~15-16 minutes) — voir "Temps de démarrage" ci-dessus.
Une optimisation possible non explorée : parallélisation de
l'entraînement `fr`/`en` (actuellement séquentiel,
`PipelineRegistry.initialize`).
- **`CONFIDENCE_THRESHOLD` côté `apps/api` est un placeholder** depuis
l'élargissement du corpus à ~74 techniques (calibré à la main, pas via
une vraie repasse de `calibrate-tech-step-threshold.ts` contre
`TECH_STEP_EVAL_DATASET` — voir le commentaire de cette constante).
- **Textcat bag-of-words** (`spacy.TextCatBOW.v3`) — suffisant pour le
corpus actuel une fois correctement entraîné, mais n'exploite pas les
vecteurs de mots des modèles `md` chargés. Migrable vers une architecture
tok2vec/similarité sans changer le contrat HTTP, si le F1 mesuré par
`apps/api/src/scripts/calibrate-tech-step-threshold.ts` le justifie un
jour.
- **Reconstruit tout le pipeline à chaque démarrage** (pas de persistance,
pas de fusion incrémentale) — un choix délibéré (voir
`LocalePipeline.train`), pas une limitation à lever : `training_data.py`
doit toujours rester l'unique source de vérité, jamais un état sur disque
qui pourrait dériver.

View file

@ -1,282 +0,0 @@
"""Maintainer script — equalizes every technique's `utterances` count
(per locale) to the corpus's own current maximum for that locale, never a
fixed number picked in the abstract. Preserves every existing utterance,
synonym, and comment verbatim; only ever *adds*, never rewrites or removes.
**Why "equalize to the current max", not "pad everyone to 20"** this
script's own history: three earlier attempts forced every technique up to
a flat 20 `utterances`/locale (12-17 new ones per technique on average).
All three measurably *failed*
`test/recipe-matching/tech-step-eval.test.ts`'s F1 >= 0.8 regression gate
(0.7999 -> 0.791 -> 0.744, each attempt worse than the last), regardless of
whether the added content was mostly generic modal-frame padding ("il
faut ...") or mostly synonym substitution. The common factor across all
three wasn't *how* the filler was generated, it was *how much*: this
corpus's real per-technique max was only 7 (fr) / 5 (en) before any of
this forcing every technique up to 20 meant most of them tripled or
quadrupled in size on synthetic content alone, which measurably hurt
inter-class separability more than it helped. Equalizing to the corpus's
*own* current max instead means at most a few new utterances per
technique (most need 1-4), which is a small enough addition to plausibly
preserve the F1 gate while still satisfying "same amount of signal per
class" (the actual goal — consistent detection quality across techniques,
not a specific round number).
**Generation strategy** synonym substitution first (see
`_synonym_variants`): for every existing utterance whose leading phrase
exactly matches one of the technique's own `synonyms`, swap in every
*other* synonym from the same list (e.g. `melt`'s "faire fondre le
beurre" -> "liquéfier le beurre") — genuinely technique-distinguishing
vocabulary, not filler shared across every class. A technique whose
`synonyms` only ever appear *mid-sentence* (the "cut style" techniques
`julienne`, `brunoise`, `mirepoix`, `paysanne`... e.g. "couper les
carottes en julienne" doesn't *start* with any of `julienne`'s own
synonyms) has no leading-phrase match to substitute, so a small modal-frame
fallback (`_FR_FRAMES`/`_EN_FRAMES`, 2 per locale much smaller than the
12/10 used in the failed 20-target attempts) closes the remainder. Safe at
this scale specifically *because* the gap being closed is small (equalizing
to the corpus's own current max, 1-4 utterances short per technique, not
13-17) see this module's own doc comment above for why volume, not
generation method, was the real problem in every failed attempt.
Run from `services/tech-step-intent-service/` (this directory):
`./.venv/Scripts/python.exe augment_utterances.py`. Rewrites
`training_data.py` in place by textual splicing (AST only to *locate* each
`utterances=[...]` list's line range — never to regenerate the file). Safe
to re-run: a technique already at the current per-locale max is left
untouched, and the max itself is recomputed from the file's *current*
state each time (so re-running after a manual edit re-equalizes against
whatever the new max is, not a stale one).
"""
import ast
import sys
SRC_PATH = "intent_service/training_data.py"
# Minimal fallback pool — only ever used for the small remainder synonym
# substitution can't reach (see this module's own doc comment for why 2,
# not the 12/10 tried in earlier, failed attempts).
_FR_FRAMES = ["il faut {u}", "veillez à {u}"]
_EN_FRAMES = ["make sure to {u}", "remember to {u}"]
def _is_fr_infinitive_led(u: str) -> bool:
first = u.split(" ", 1)[0].lower()
return first.endswith(("er", "ir", "re")) and len(first) > 2
_EN_VERB_WHITELIST = {
"make", "add", "pour", "mix", "stir", "cut", "place", "cover", "remove", "heat", "let",
"keep", "turn", "cook", "bake", "roast", "grill", "fry", "boil", "simmer", "whisk", "fold",
"chop", "mince", "peel", "drain", "season", "rest", "plate", "coat", "melt", "sauté", "saute",
"braise", "blanch", "marinate", "brown", "glaze", "thicken", "reduce", "dilute", "loosen",
"moisten", "sift", "toast", "zest", "scald", "pod", "shell", "hollow", "shock", "emulsify",
"decant", "dust", "sweat", "rub", "punch", "confit", "caramelize", "score", "line", "clarify",
"stew", "dice", "fillet", "proof", "poach", "pasteurize", "sterilize", "can", "preserve",
"tie", "truss", "baste", "spoon", "brush", "whip", "beat", "work", "sear", "flatten", "press",
"knead", "run", "cool", "warm", "combine", "blend", "arrange", "present", "sprinkle", "strain",
"separate", "bring", "grate", "continue", "deglaze", "scrape", "char", "break", "slice", "set",
"adjust", "switch", "secure", "mark", "butter", "crush", "julienne", "reheat", "smother",
"build", "scoop", "plunge", "increase", "pass", "collect", "have", "salt", "soak",
}
_EN_ADVERB_SKIP = {
"coarsely", "roughly", "finely", "quickly", "lightly", "briefly", "gently", "carefully",
"gradually", "very", "thoroughly", "evenly", "generously", "slowly", "thinly", "deep", "blind",
"dry",
}
def _is_en_imperative_led(u: str) -> bool:
words = u.lower().replace(",", "").split()
if not words:
return False
first = words[0]
if first in _EN_VERB_WHITELIST:
return True
if first in _EN_ADVERB_SKIP and len(words) > 1:
return words[1] in _EN_VERB_WHITELIST
return False
def _frame_variants(existing: list[str], frames: list[str], is_led) -> list[str]:
sources = [u for u in existing if is_led(u)]
if not sources:
return []
seen = set(existing)
out: list[str] = []
for frame in frames:
for u in sources:
candidate = frame.format(u=u)
if candidate in seen:
continue
seen.add(candidate)
out.append(candidate)
return out
def _synonym_variants(existing: list[str], synonyms: list[str], locale: str) -> list[str]:
"""Substitutes every *other* synonym in place of whichever synonym an
existing utterance's leading phrase exactly matches — see this
module's own doc comment for why this is the primary generation
strategy.
Both the matched *and* the replacement synonym must independently pass
`_is_fr_infinitive_led`/`_is_en_imperative_led` a technique's
`synonyms` list mixes genuine verb forms ("mijoter", "frémir") with
noun/adjective phrases used the same way a keyword-matcher needs them
but never as a sentence's own leading verb ("à petit feu", "gros
bouillons", "huile de friture") — without this check, swapping the
verb "frémir" for the noun phrase "à petit feu" inside "laisser
frémir..." produces a syntactically broken sentence ("à petit feu
..."), not just a stylistically different one. Filtering the
replacement pool to the same grammatical shape as the ones this
function already accepts as *sources* keeps every substitution a
like-for-like swap."""
if len(synonyms) < 2:
return []
is_led = _is_fr_infinitive_led if locale == "fr" else _is_en_imperative_led
seen = set(existing)
sorted_synonyms = sorted({syn for syn in synonyms if is_led(syn)}, key=len, reverse=True)
if len(sorted_synonyms) < 2:
return []
out: list[str] = []
for u in existing:
lower_u = u.lower()
matched = next(
(
syn
for syn in sorted_synonyms
if lower_u == syn.lower() or lower_u.startswith(f"{syn.lower()} ")
),
None,
)
if matched is None:
continue
rest = u[len(matched) :]
for syn in sorted_synonyms:
if syn == matched:
continue
candidate = f"{syn}{rest}"
if candidate in seen:
continue
seen.add(candidate)
out.append(candidate)
return out
def top_up(existing: list[str], synonyms: list[str], target: int, locale: str) -> list[str]:
if len(existing) >= target:
return []
needed = target - len(existing)
pool = _synonym_variants(existing, synonyms, locale)
if len(pool) < needed:
frames = _FR_FRAMES if locale == "fr" else _EN_FRAMES
is_led = _is_fr_infinitive_led if locale == "fr" else _is_en_imperative_led
already = set(existing) | set(pool)
for candidate in _frame_variants(existing, frames, is_led):
if candidate in already:
continue
pool.append(candidate)
already.add(candidate)
return pool[:needed]
def main() -> None:
with open(SRC_PATH, encoding="utf-8") as f:
source = f.read()
tree = ast.parse(source)
lines = source.splitlines(keepends=True)
module_body = tree.body
training_data_list = None
for node in module_body:
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
if node.target.id == "TECH_STEP_TRAINING_DATA":
training_data_list = node.value
break
if training_data_list is None or not isinstance(training_data_list, ast.List):
print("Could not locate TECH_STEP_TRAINING_DATA list", file=sys.stderr)
sys.exit(1)
# First pass: collect every entry's current per-locale utterance/synonym
# lists and find each locale's own current max — the equalization
# target, not a number picked separately from the corpus itself.
parsed: list[tuple[str, str, ast.List, list[str], list[str]]] = []
targets = {"fr": 0, "en": 0}
for entry_call in training_data_list.elts:
assert isinstance(entry_call, ast.Call)
uid = None
for kw in entry_call.keywords:
if kw.arg == "uid":
assert isinstance(kw.value, ast.Constant)
uid = kw.value.value
for kw in entry_call.keywords:
if kw.arg not in ("fr", "en"):
continue
locale = kw.arg
locale_call = kw.value
assert isinstance(locale_call, ast.Call)
utterances_list_node = None
synonyms_list_node = None
for inner_kw in locale_call.keywords:
if inner_kw.arg == "utterances":
utterances_list_node = inner_kw.value
elif inner_kw.arg == "synonyms":
synonyms_list_node = inner_kw.value
if utterances_list_node is None:
continue
assert isinstance(utterances_list_node, ast.List)
existing = [
elt.value for elt in utterances_list_node.elts if isinstance(elt, ast.Constant)
]
synonyms = (
[elt.value for elt in synonyms_list_node.elts if isinstance(elt, ast.Constant)]
if isinstance(synonyms_list_node, ast.List)
else []
)
targets[locale] = max(targets[locale], len(existing))
parsed.append((uid, locale, utterances_list_node, existing, synonyms))
print(f"Equalizing to the corpus's own current max — fr: {targets['fr']}, en: {targets['en']}")
insertions: list[tuple[int, str, list[str]]] = []
total_added = 0
shortfalls: list[tuple[str, str, int]] = []
for uid, locale, utterances_list_node, existing, synonyms in parsed:
target = targets[locale]
new_ones = top_up(existing, synonyms, target, locale)
final_count = len(existing) + len(new_ones)
if final_count < target:
shortfalls.append((uid, locale, final_count))
if not new_ones:
continue
last_elt = utterances_list_node.elts[-1]
insert_after_line = last_elt.end_lineno - 1
indent = lines[insert_after_line][
: len(lines[insert_after_line]) - len(lines[insert_after_line].lstrip())
]
new_lines = [f'{indent}"{s}",\n' for s in new_ones]
insertions.append((insert_after_line, uid, new_lines))
total_added += len(new_ones)
insertions.sort(key=lambda t: t[0], reverse=True)
for line_idx, uid, new_lines in insertions:
lines[line_idx + 1 : line_idx + 1] = new_lines
with open(SRC_PATH, "w", encoding="utf-8", newline="\n") as f:
f.writelines(lines)
print(f"Added {total_added} new utterances across {len(insertions)} (technique, locale) pairs.")
if shortfalls:
print(f"{len(shortfalls)} (uid, locale) pair(s) still below their locale's target — not")
print("enough synonym variety to reach full equalization:")
for uid, locale, count in shortfalls:
print(f" {uid} ({locale}): {count}/{targets[locale]}")
else:
print("Every technique now has exactly the same utterance count as every other, per locale.")
if __name__ == "__main__":
main()

View file

@ -1,12 +0,0 @@
"""Microservice de détection d'intention (technique de cuisine).
Remplace le pipeline `node-nlp` qui vivait dans `apps/api`
(`TechStepClassifierService`, `apps/api/src/lib/recipe-matching/tech-step-matcher.ts`) :
NER par phrases (synonymes) + classification d'intention (textcat), les deux
entraînés à la demande depuis un corpus qui reste possédé par `apps/api`
(`TECH_STEP_TRAINING_DATA`) et poussé ici via `POST /v1/train`.
Ce service ne touche jamais Postgres voir `services/tech-step-llm-worker`
pour le précédent architectural (même posture : aucun accès DB direct,
tout passe par HTTP, la résolution `TechStep.key -> id` reste côté `apps/api`).
"""

View file

@ -1,52 +0,0 @@
"""Configuration du service, lue depuis l'environnement (`pydantic-settings`).
Contrairement à `requireInternalWorker` côté `apps/api`
(`apps/api/src/middlewares/require-internal-worker.ts`), qui tolère un
`INTERNAL_WORKER_SECRET` absent (le worker LLM est un job de fond
optionnel) et échoue "juste" requête par requête dans ce cas, ce service est
une dépendance coeur : `INTENT_SERVICE_SECRET` absent doit empêcher
`uvicorn` de démarrer du tout plutôt que de démarrer dans un état chaque
requête échouerait silencieusement en boucle `Settings` n'a donc aucune
valeur par défaut ni type optionnel pour ce champ, la validation Pydantic
lève dès l'import de ce module si la variable manque.
"""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# `env_file=".env"` : lu uniquement en dev natif (`cp .env.example .env`,
# voir le README de ce service) — sans effet en Docker, où
# docker-compose.yml passe les variables directement en `environment:`
# et où aucun `.env` n'est copié dans l'image. Un `.env` absent n'est pas
# une erreur ici (pydantic-settings ignore silencieusement un fichier
# manquant) ; c'est bien `intent_service_secret` ci-dessous, sans valeur
# par défaut, qui fait échouer le démarrage si la variable n'est
# disponible par aucune des deux voies.
#
# `case_sensitive` par défaut (False) : `INTENT_SERVICE_SECRET` (la
# convention majuscule utilisée partout ailleurs dans le repo, cf.
# `docker-compose.yml`/`.env.example`) matche bien le champ
# `intent_service_secret` ci-dessous.
model_config = SettingsConfigDict(env_file=".env")
# Secret partagé attendu sur le header `X-Intent-Service-Secret` de
# chaque requête (sauf `GET /health`) — voir `security.py`. Doit matcher
# `INTENT_SERVICE_SECRET` côté `apps/api/src/config/env.ts`.
intent_service_secret: str
# Pas de `port` ici : `uvicorn` prend son port en argument de ligne de
# commande (`--port`, voir le Dockerfile et le README de ce service),
# jamais lu depuis `Settings` — une variable d'env dupliquant ce que la
# commande de démarrage fixe déjà explicitement n'aurait aucun lecteur.
# Niveau du logging structuré (`logging_config.py`) — voir ce module pour
# le format. `INFO` par défaut : c'est à ce niveau que `routes/process.py`
# journalise chaque input/output du pipeline NLP, et que
# `pipeline_registry.py` journalise l'entraînement au démarrage, pour
# qu'un déploiement par défaut les voie sans configuration
# supplémentaire (`docker logs`/Portainer).
log_level: str = "INFO"
settings = Settings()

View file

@ -1,481 +0,0 @@
"""Pipeline spaCy pour UNE locale — l'équivalent Python de ce que
`node-nlp`'s `NlpManager` faisait pour cette locale dans
`TechStepClassifierService` (`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`) :
NER par entités enum (ici un `PhraseMatcher`) + classification d'intention
(ici un `textcat`), les deux entraînés à partir du corpus possédé par ce
service lui-même (`training_data.TECH_STEP_TRAINING_DATA` plus poussé par
`apps/api` via HTTP, voir `pipeline_registry.py`).
Le modèle de base spaCy (tokenizer + vecteurs + le composant
`diacritics_normalizer` défini plus bas) est chargé une seule fois
(`preload()`, appelé au démarrage du process voir `main.py` pas
paresseusement au premier `train()`, pour que `GET /health` ne devienne
`200` qu'une fois ce coût payé) puis réutilisé à chaque `train()` : seul le
`textcat` (retiré puis rajouté à neuf) et le `PhraseMatcher` (remplacé) sont
reconstruits à chaque appel, jamais le tokenizer/les vecteurs. Rien n'est
jamais persisté sur disque `training_data.py` reste l'unique source de
vérité, reconstruite en mémoire depuis zéro à chaque démarrage du process.
"""
from __future__ import annotations
import logging
import random
from dataclasses import dataclass, field
import spacy
from spacy.language import Language
from spacy.matcher import PhraseMatcher
from spacy.tokens import Doc, Span
from spacy.training import Example
from spacy.util import filter_spans, fix_random_seed, minibatch
from . import utensil_vocabulary
from .text_normalization import normalize_text
logger = logging.getLogger(__name__)
# Modèle spaCy de base par locale — voir pyproject.toml pour la version
# pinnée exacte. `md` (pas `sm`) : conserve les vecteurs de mots, inutilisés
# par le pipeline v1 (textcat bag-of-words) mais retenus pour l'ambition
# future de similarité sémantique (voir le README de ce service).
SUPPORTED_LOCALES = {
"fr": "fr_core_news_md",
"en": "en_core_web_md",
}
# Composants du modèle de base non utilisés par ce pipeline (on ne s'appuie
# ni sur le NER générique de spaCy, ni sur l'analyse syntaxique/morphologique
# — seuls le tokenizer et les vecteurs de mots restent nécessaires) : les
# exclure au chargement évite le coût mémoire/CPU de composants qui ne
# tourneraient jamais.
_EXCLUDED_COMPONENTS = ["parser", "ner", "tagger", "morphologizer", "attribute_ruler", "lemmatizer"]
_TEXTCAT_PIPE_NAME = "textcat"
# Nombre d'itérations d'entraînement du textcat et taille de minibatch —
# calibrés empiriquement contre le corpus réel (`training_data.py`), pas
# seulement contre les petits corpus jouets des tests de ce fichier. Trop
# peu d'itérations laisse des clauses correctement classifiées (bon argmax)
# mais avec une confiance dérisoire — bien en dessous de tout seuil
# raisonnable pour `CONFIDENCE_THRESHOLD` (`tech-step-matcher.ts`).
#
# Trois passes de calibration successives, toutes mesurées contre le
# corpus réel (74 techniques) :
# 1. `150` itérations (calibré pour le corpus original, ~26 techniques) ne
# passe plus à l'échelle une fois élargi : `150` sur 74 classes
# dépassait 17 minutes pour une seule locale, constaté en CI.
# 2. `40` itérations, `examples` limité aux `utterances` (pas les
# `synonyms`) : ~200s/locale, mais confiance faible sur les clauses
# ancrées sans paraphrase entraînée (`simmer`/`cook`/`bake` ~0.25-0.34).
# 3. **Configuration actuelle** : les `synonyms` de chaque technique sont
# désormais aussi des exemples d'entraînement du textcat (voir plus bas
# dans `train()`) — un signal "mot-clé isolé -> sa propre technique"
# qui manquait complètement avant. À `_TRAINING_ITERATIONS` inchangé
# (40), le nombre d'exemples par époque grimpe de ~286 à ~749 et le
# temps d'entraînement suit (~535s/locale) ; réduire à `25` retrouve un
# temps proche de l'étape 2 (~336s/locale, ~670s pour fr+en combinés)
# tout en gardant l'essentiel du gain de confiance apporté par les
# synonymes : melt ~0.89, preheat ~0.77, compote ~0.78, julienne ~0.76,
# zest ~0.66, bake ~0.62, cook ~0.38, simmer ~0.31 — le plus faible
# observé, mais désormais nettement au-dessus du seuil de confiance
# (contre ~0.25, sous le seuil d'alors, à l'étape 2). Bruit
# hors-vocabulaire toujours négligeable (anglais via le classifieur
# français : `~0.02`). Une vraie repasse de
# `calibrate-tech-step-threshold.ts` contre `TECH_STEP_EVAL_DATASET`
# reste nécessaire pour confirmer/affiner ces valeurs (voir
# `CONFIDENCE_THRESHOLD`'s propre commentaire, `tech-step-matcher.ts`)
# — ce qui précède est une mesure manuelle ponctuelle, pas un
# remplacement de cette calibration.
_TRAINING_ITERATIONS = 25
_TRAINING_BATCH_SIZE = 16
# Arrêt anticipé : `_TRAINING_ITERATIONS` reste le plafond (le pire cas ne
# change pas), un corpus/locale qui converge plus vite n'a pas à payer les
# itérations restantes pour rien. Une époque compte comme "sans progrès"
# quand sa perte totale ne descend pas d'au moins `_EARLY_STOPPING_MIN_DELTA`
# sous la meilleure perte vue jusqu'ici ; `_EARLY_STOPPING_PATIENCE` époques
# consécutives sans progrès arrêtent l'entraînement.
#
# Mesuré contre le corpus réel (74 techniques, budget de 40 itérations,
# avant le passage à 25) : ne s'est jamais déclenché — la perte continuait
# de baisser significativement sur toute la plage (cohérent avec la
# confiance qui grimpait encore nettement entre 15 et 40 itérations, voir
# le commentaire de `_TRAINING_ITERATIONS`). Ce n'est donc pas un gain de
# temps aujourd'hui, mais un filet de sécurité peu coûteux pour la suite : si
# `_TRAINING_ITERATIONS` est un jour augmenté pour une meilleure confiance,
# ceci évite de payer des itérations supplémentaires une fois la
# convergence réellement atteinte, sans qu'il faille retrouver le bon
# plafond à la main à chaque changement du corpus.
_EARLY_STOPPING_PATIENCE = 3
_EARLY_STOPPING_MIN_DELTA = 0.001
# Abaissé de `0.2` avec le reste de cette recalibration — `0.1` régularise
# encore contre la petite taille du corpus par technique tout en laissant
# plus de signal passer à chaque pas, ce qui a mesurablement aidé la
# confiance finale sans signe de sur-ajustement (le bruit hors-vocabulaire
# reste aussi bas qu'avant, voir ci-dessus).
_TRAINING_DROPOUT = 0.1
# Seed fixe — un warm-up reproductible d'un redémarrage à l'autre (même
# corpus en entrée) est préférable à un score qui varie légèrement à chaque
# déploiement pour la même donnée, en particulier pendant la calibration du
# seuil de confiance côté apps/api.
_TRAINING_SEED = 0
class _DiacriticsNormalizer:
"""Composant de pipeline réécrivant `token.norm_` avec `normalize_text()`
(le port Python de `normalizeText()` côté `apps/api`) pour chaque token.
Point clé : ce composant tourne aussi bien sur les `Doc` construits pour
les *patterns* du `PhraseMatcher` (voir `LocalePipeline.train`) que sur
le *texte cible* passé à `process()` les deux passent donc par
exactement la même normalisation, ce qui garantit qu'un synonyme comme
"mijoter" matche indifféremment "MIJOTER"/"mijoté"/"Mijotée" dans le
texte, reproduisant le comportement `ner.threshold: 1` (exact après
normalisation, sans tolérance floue Levenshtein) de l'ancien `NlpManager`.
Indépendant des `entries` entraînées ajouté une seule fois par
`preload()`, jamais retiré/rajouté par `train()`.
Opère token par token, sur du texte déjà tokenisé `normalize_text()`
ne fait que réécrire la forme d'un token existant (minuscule, sans
diacritique), jamais fusionner/scinder des tokens : les patterns
(`nlp.make_doc(synonym)` + ce composant appliqué à la main, voir
`LocalePipeline.train`) et le texte cible (`nlp(text)`, pipeline
complet) passent donc toujours par le *même* découpage en tokens que
le tokenizer du modèle de base leur donne, avant que ce composant n'y
touche pas de risque de désalignement entre les deux.
"""
def __call__(self, doc: Doc) -> Doc:
for token in doc:
token.norm_ = normalize_text(token.text)
return doc
@Language.factory("diacritics_normalizer")
def _create_diacritics_normalizer(nlp: Language, name: str) -> _DiacriticsNormalizer:
return _DiacriticsNormalizer()
@dataclass
class TrainEntry:
"""Une technique à entraîner pour une locale — construit par
`PipelineRegistry.initialize()` depuis `training_data.entries_for_locale`."""
uid: str
synonyms: list[str] = field(default_factory=list)
utterances: list[str] = field(default_factory=list)
@dataclass
class Entity:
"""Une mention candidate trouvée par un `PhraseMatcher` — offsets
caractère `[start, end)`, miroir de `EntityPayload` (`schemas.py`).
`kind` distingue de quel `PhraseMatcher` la mention vient (`"technique"`
`self._matcher`, entraîné depuis `training_data.py` ou `"utensil"`
`self._utensil_matcher`, statique, voir `utensil_vocabulary.py`) :
`apps/api`'s `tech-step-matcher.ts` a besoin de savoir laquelle des deux
résoudre (`TechStep.key` vs `Utensil.key`)."""
uid: str
start: int
end: int
kind: str = "technique"
@dataclass
class ProcessResult:
"""Résultat complet d'un `process()` — miroir de `ProcessResponse`
(`schemas.py`)."""
entities: list[Entity]
intent: str | None
score: float
class UnsupportedLocaleError(ValueError):
"""`locale` ne correspond à aucun modèle spaCy connu (voir
`SUPPORTED_LOCALES`) distinct d'une locale simplement "pas encore
entraînée" (`LocalePipeline.is_trained is False`), qui n'est pas une
erreur (voir `process()`)."""
class LocalePipeline:
"""Pipeline spaCy (NER par phrases + textcat) pour une locale donnée.
Un `PipelineRegistry` (voir `pipeline_registry.py`) en détient une
instance par locale supportée.
"""
def __init__(self, locale: str) -> None:
if locale not in SUPPORTED_LOCALES:
raise UnsupportedLocaleError(f"Unsupported locale: {locale!r}")
self._locale = locale
self._model_name = SUPPORTED_LOCALES[locale]
# `None` tant que `preload()` n'a pas tourné.
self._base_nlp: Language | None = None
# `None` tant qu'aucun `train()` n'a réussi — `process()` traite ça
# comme "rien à trouver" plutôt qu'une erreur, exactement le
# comportement testé côté `apps/api` pour "une locale jamais
# entraînée".
self._matcher: PhraseMatcher | None = None
# Construit une seule fois par `preload()`, jamais par `train()` —
# contrairement à `self._matcher`, ce vocabulaire est statique
# (`utensil_vocabulary.py`), il n'a pas de contrepartie "corpus
# poussé par un appelant" à reconstruire.
self._utensil_matcher: PhraseMatcher | None = None
self._trained = False
@property
def is_trained(self) -> bool:
return self._trained
def preload(self) -> None:
"""Charge le modèle spaCy de base (tokenizer + vecteurs), le
composant `diacritics_normalizer`, et construit le `PhraseMatcher`
d'ustensiles — idempotent, sans effet si déjà chargé. Appelé au
démarrage du process pour les deux locales connues (voir
`main.py`), pas paresseusement au premier `train()`.
Le matcher d'ustensiles est construit ici, pas dans `train()` :
contrairement au `PhraseMatcher` de techniques (reconstruit à
chaque `train()` depuis les `entries` reçues), le vocabulaire
d'ustensiles est statique (`utensil_vocabulary.py`) — rien ne le
fait jamais varier d'un appel à l'autre, donc rien ne justifie de
payer son coût de construction plus d'une fois par démarrage.
"""
if self._base_nlp is not None:
return
nlp = spacy.load(self._model_name, exclude=_EXCLUDED_COMPONENTS)
nlp.add_pipe("diacritics_normalizer", first=True)
self._base_nlp = nlp
diacritics_normalizer = nlp.get_pipe("diacritics_normalizer")
utensil_matcher = PhraseMatcher(nlp.vocab, attr="NORM")
for uid, synonyms in utensil_vocabulary.synonyms_for_locale(self._locale).items():
if not synonyms:
continue
patterns = [diacritics_normalizer(nlp.make_doc(synonym)) for synonym in synonyms]
utensil_matcher.add(uid, patterns)
self._utensil_matcher = utensil_matcher
def train(self, entries: list[TrainEntry]) -> tuple[int, int, int]:
"""Reconstruit le `textcat` et le `PhraseMatcher` de ce pipeline à
partir de `entries` (le tokenizer/les vecteurs restent ceux chargés
par `preload()`). Retourne `(label_count, example_count,
synonym_count)` pour la journalisation (`pipeline_registry.py`)
`example_count` est le nombre réel d'exemples donnés au `textcat`
(`utterances` *et* `synonyms` combinés, voir plus bas), pas
seulement `entry.utterances`.
`entries` vide retombe à `is_trained == False` plutôt que de lever
un appelant qui n'a rien à entraîner pour cette locale obtient le
même comportement que "jamais entraîné", pas une erreur 500.
"""
self.preload()
assert self._base_nlp is not None # garanti par preload() ci-dessus
if _TEXTCAT_PIPE_NAME in self._base_nlp.pipe_names:
self._base_nlp.remove_pipe(_TEXTCAT_PIPE_NAME)
if not entries:
self._matcher = None
self._trained = False
return (0, 0, 0)
nlp = self._base_nlp
# `nlp.make_doc()` ne fait tourner *que* le tokenizer, pas les
# composants du pipeline — le `diacritics_normalizer` ajouté par
# `preload()` ne tournerait donc jamais sur les `Doc` de patterns
# s'ils n'étaient construits qu'avec `make_doc()`, alors que
# `process()` appelle `nlp(text)` (le pipeline complet) sur le texte
# cible. Sans ce correctif, un synonyme accentué comme "préchauffer"
# n'aurait jamais matché "PRÉCHAUFFER"/"Préchauffer" : trouvé en
# calibrant contre les cas exacts de `tech-step-matcher.test.ts`
# (fr, la locale la plus concernée par les accents) — un synonyme
# sans diacritique comme "faire fondre" masquait le bug en semblant
# fonctionner par coïncidence. Appliquer explicitement le même
# composant aux deux côtés garantit qu'ils passent par la même
# normalisation.
diacritics_normalizer = nlp.get_pipe("diacritics_normalizer")
matcher = PhraseMatcher(nlp.vocab, attr="NORM")
synonym_count = 0
for entry in entries:
if not entry.synonyms:
continue
patterns = [diacritics_normalizer(nlp.make_doc(synonym)) for synonym in entry.synonyms]
matcher.add(entry.uid, patterns)
synonym_count += len(entry.synonyms)
# `textcat` (exclusive_classes) exige au moins deux labels (voir
# spaCy's error E867) — jamais un problème avec le vrai corpus
# (`TECH_STEP_TRAINING_DATA` a ~74 techniques), mais un `entries` à
# un seul élément resterait structurellement valide pour le NER
# seul : ne pas planter, juste ne pas construire de textcat du tout
# (`process()` retombe alors sur `intent: null` via son garde
# `if not cats`, exactement comme "rien à classifier"). Journalisé
# explicitement — sans ça, "pourquoi cette locale ne classifie
# jamais rien" ne serait visible qu'en déduisant `labelCount < 2`
# de la ligne "tech-step NLP pipeline trained" (`pipeline_registry.py`).
examples: list[Example] = []
if len(entries) < 2:
logger.warning(
"tech-step NLP textcat skipped: fewer than 2 labels, intent classification disabled for this locale",
extra={"locale": self._locale, "labelCount": len(entries)},
)
else:
textcat = nlp.add_pipe(
_TEXTCAT_PIPE_NAME,
config={
"model": {
"@architectures": "spacy.TextCatBOW.v3",
"exclusive_classes": True,
"ngram_size": 1,
"no_output_layer": False,
},
},
)
for entry in entries:
textcat.add_label(entry.uid)
for entry in entries:
cats = {other.uid: 0.0 for other in entries}
cats[entry.uid] = 1.0
# `synonyms` (déjà utilisés pour le `PhraseMatcher` ci-dessus)
# sont aussi de bonnes phrases d'entraînement pour le
# `textcat` — un texte réduit au mot-clé lui-même ("fondre",
# "faire fondre") est le cas le plus net qui soit pour sa
# propre technique, et n'était auparavant vu par le textcat
# que noyé dans le contexte plus riche des `utterances`.
for text in (*entry.synonyms, *entry.utterances):
doc = nlp.make_doc(text)
examples.append(Example.from_dict(doc, {"cats": cats}))
# Graine le RNG Python *et* celui de numpy/thinc sous-jacent à
# `nlp.update()` (initialisation des poids, masque de dropout) —
# `random.Random(_TRAINING_SEED)` ci-dessous ne couvre que l'ordre
# de mélange des exemples choisi par ce module, pas ce que spaCy
# fait en interne à chaque pas de gradient.
fix_random_seed(_TRAINING_SEED)
rng = random.Random(_TRAINING_SEED)
if examples:
optimizer = nlp.initialize(lambda: examples)
best_loss = float("inf")
epochs_without_improvement = 0
for iteration in range(_TRAINING_ITERATIONS):
rng.shuffle(examples)
losses: dict[str, float] = {}
for batch in minibatch(examples, size=_TRAINING_BATCH_SIZE):
nlp.update(batch, sgd=optimizer, drop=_TRAINING_DROPOUT, losses=losses)
epoch_loss = losses.get(_TEXTCAT_PIPE_NAME, 0.0)
# Arrêt anticipé — voir `_EARLY_STOPPING_PATIENCE`'s propre
# commentaire. `_TRAINING_ITERATIONS` reste le plafond
# (pire cas inchangé), ceci ne fait que raccourcir les
# cas qui convergent plus vite.
if epoch_loss < best_loss - _EARLY_STOPPING_MIN_DELTA:
best_loss = epoch_loss
epochs_without_improvement = 0
else:
epochs_without_improvement += 1
if epochs_without_improvement >= _EARLY_STOPPING_PATIENCE:
logger.info(
"tech-step NLP textcat training stopped early",
extra={
"locale": self._locale,
"iteration": iteration + 1,
"maxIterations": _TRAINING_ITERATIONS,
"finalLoss": epoch_loss,
},
)
break
else:
# Des `entries` avec des `uid` mais aucune `utterance` nulle
# part (corpus incomplet) : le textcat a des labels mais rien
# pour apprendre à les distinguer — toujours initialisé pour
# rester un pipeline valide ; `process()` renverra alors un
# score ~uniforme entre labels. Ce n'est pas ce module qui doit
# juger la qualité du corpus reçu (voir `tech-step-eval-runner.ts`
# côté apps/api pour ce rôle).
nlp.initialize()
self._matcher = matcher
self._trained = True
return (len(entries), len(examples), synonym_count)
def process(self, text: str) -> ProcessResult:
"""Reproduit la forme de `NlpManager.process(locale, text)` : les
entités candidates (NER) et le verdict du classifieur d'intention
sur `text` tel quel que ce soit la description complète ou une
clause déjà découpée côté `apps/api`, ce module ne le sait pas et ne
s'en soucie pas, exactement comme l'ancien `NlpManager`.
`intent` vaut `None` dans deux cas distincts, tous deux silencieux
côté retour (voir le log d'avertissement de `train()` pour repérer
le second en amont) : `text` vide/blanc, ou `doc.cats` vide parce
que `train()` a reçu moins de deux labels pour cette locale (le
textcat n'a alors jamais été construit — voir son propre
commentaire).
"""
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)
doc = self._base_nlp(text)
# A technique's own synonym list can legitimately contain one phrase
# nested inside another (`melt`'s "fondre" is a literal substring of
# its own "faire fondre") — the `PhraseMatcher` reports *both* as
# separate matches at overlapping positions, which without
# resolution would hand `splitIntoClauses` (apps/api) two candidates
# for what a human reads as one mention, producing the same
# techStepId twice in the final result. `filter_spans` keeps only
# the longest match at each position (so "faire fondre" wins over
# the "fondre" it contains) — found by a real regression in
# `tech-step-matcher.test.ts`'s "detects several distinct
# techniques..." case once this service replaced node-nlp (which
# apparently resolved this internally; nothing here recreates that
# by choice, `filter_spans` is spaCy's own documented tool for
# exactly this "one span per position" problem, e.g. as used for
# NER-style outputs).
matched_spans = [
Span(doc, start, end, label=match_id) for match_id, start, end in self._matcher(doc)
]
technique_entities = [
Entity(
uid=self._base_nlp.vocab.strings[span.label],
start=span.start_char,
end=span.end_char,
kind="technique",
)
for span in filter_spans(matched_spans)
]
# Second, independent `PhraseMatcher` pass for ustensiles — run and
# `filter_spans`-resolved *separately* from the technique pass
# above: the two matchers' candidates never compete for the same
# position (a longer utensil match must never swallow/be swallowed
# by a technique match the way two overlapping technique synonyms
# do), only overlaps *within* the same matcher are the known
# problem `filter_spans` exists for (see the technique pass's own
# comment above).
utensil_entities: list[Entity] = []
if self._utensil_matcher is not None:
utensil_spans = [
Span(doc, start, end, label=match_id)
for match_id, start, end in self._utensil_matcher(doc)
]
utensil_entities = [
Entity(
uid=self._base_nlp.vocab.strings[span.label],
start=span.start_char,
end=span.end_char,
kind="utensil",
)
for span in filter_spans(utensil_spans)
]
entities = sorted(technique_entities + utensil_entities, key=lambda entity: entity.start)
cats = doc.cats
if not cats:
return ProcessResult(entities=entities, intent=None, score=0.0)
intent = max(cats, key=cats.get)
return ProcessResult(entities=entities, intent=intent, score=cats[intent])

View file

@ -1,77 +0,0 @@
"""Logging structuré — même convention que `LoggerService` côté `apps/api`
(`apps/api/src/lib/logger.service.ts`) : une ligne JSON par évènement
(`timestamp`, `level`, `message`, + le reste des champs fournis fusionné),
jamais du texte libre, pour rester grep/parse-able par `docker logs`/
Portainer ou un agrégateur de logs cohérent avec le reste du repo plutôt
qu'un format propre à ce seul service.
Configuré une fois au démarrage (`main.py`) plutôt que par un `print()` ad
hoc dans chaque route `routes/process.py`/`pipeline_registry.py` appellent
`logging.getLogger(__name__)` normalement, ce module ne fait que brancher le
formateur JSON sur la racine du logging Python.
"""
import json
import logging
import sys
from datetime import UTC, datetime
from typing import Any
class _JsonFormatter(logging.Formatter):
"""Sérialise chaque `LogRecord` en une ligne JSON. Les champs
supplémentaires passés via `logger.info(msg, extra={...})` sont fusionnés
tels quels dans l'objet — c'est ce que `routes/process.py` utilise pour
joindre `locale`/`text`/`entities`/`intent`/`score` à la ligne."""
# Attributs standards de `LogRecord` — tout le reste posé sur le record
# (via `extra=`) est un champ métier ajouté par l'appelant, à fusionner
# dans la sortie JSON.
_STANDARD_ATTRS = frozenset(logging.LogRecord("", 0, "", 0, "", None, None).__dict__.keys())
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
"level": record.levelname.lower(),
"message": record.getMessage(),
}
extra_fields = {
key: value for key, value in record.__dict__.items() if key not in self._STANDARD_ATTRS
}
payload.update(extra_fields)
if record.exc_info:
payload["error"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False, default=str)
def configure_logging(level: str) -> None:
"""Branche le formateur JSON sur la racine du logging Python — appelé
une fois au démarrage (`main.py`), avant que `routes/*` ne journalisent
quoi que ce soit."""
# L'encodage par défaut de `sys.stdout` suit la locale de l'OS/console,
# pas forcément UTF-8 — sur Windows en particulier, garder ce défaut
# produit de vrais octets invalides (pas juste un affichage terminal
# trompeur) pour tout texte accentué journalisé par `routes/process.py`
# (le texte réel des étapes de recette, en français) — trouvé en
# vérifiant les octets bruts d'un log réel, pas juste son affichage.
# `reconfigure` existe sur `sys.stdout` dans toute exécution Python
# normale (pas dans certains contextes embarqués/redirigés exotiques) —
# protégé par `hasattr` pour ne jamais faire planter le démarrage pour un
# souci de confort d'affichage.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(_JsonFormatter())
root = logging.getLogger()
root.handlers = [handler]
root.setLevel(level)
# spaCy/thinc journalisent leur propre chatter interne ("Created
# vocabulary", "Finished initializing nlp object"...) sur le logger
# `"spacy"`, qui propage jusqu'à la racine et se retrouverait donc
# mélangé aux lignes input/output de `routes/process.py`/l'entraînement
# journalisé par `pipeline_registry.py`
# — ce sont ces dernières que ce service existe pour rendre visibles, pas
# le détail interne de spaCy. `WARNING` laisse quand même remonter un
# vrai problème (dépréciation, échec partiel) sans le bruit `INFO`.
logging.getLogger("spacy").setLevel(logging.WARNING)

View file

@ -1,39 +0,0 @@
"""Point d'entrée FastAPI — `uv run uvicorn intent_service.main:app` (voir
le Dockerfile et le README de ce service).
Le chargement des modèles spaCy de base *et* l'entraînement de chaque
locale (`PipelineRegistry.initialize`) se font dans le handler `lifespan`
ci-dessous, *avant* qu'uvicorn n'accepte de requêtes `GET /health` ne
répond donc `200` qu'une fois ce coût payé (chargement + entraînement),
jamais pendant qu'il est encore en cours (uvicorn ne sert aucune requête
tant que le `lifespan` de démarrage n'est pas terminé). Ce service est
autonome : `training_data.TECH_STEP_TRAINING_DATA` vit dans ce module,
`apps/api` ne pousse plus rien via HTTP (voir `pipeline_registry.py` pour
le détail de ce que ça change par rapport à la version précédente).
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from .config import settings
from .logging_config import configure_logging
from .pipeline_registry import registry
from .routes import health, process
# Avant tout le reste : `routes/process.py` journalise dès la première
# requête, `initialize()` ci-dessous journalise aussi (voir
# `pipeline_registry.py`) — le formateur JSON doit déjà être en place.
configure_logging(settings.log_level)
@asynccontextmanager
async def lifespan(app: FastAPI):
registry.initialize()
yield
app = FastAPI(title="tech-step-intent-service", lifespan=lifespan)
app.include_router(health.router)
app.include_router(process.router)

View file

@ -1,75 +0,0 @@
"""Détient un `LocalePipeline` par locale supportée — le seul état mutable
partagé du process (une instance vit pour toute la durée de vie d'`uvicorn`,
montée sur `app.state`, voir `main.py`).
Volontairement une classe "registre" séparée de `LocalePipeline` lui-même :
`LocalePipeline` ne connaît qu'une seule locale, ce module route `process`
vers la bonne instance selon le `locale` reçu dans la requête même
séparation de responsabilité que `TechStepClassifierService` (une seule
instance, un seul `NlpManager` multi-langues) avait implicitement via
node-nlp, explicitée ici puisque spaCy charge un modèle par langue.
"""
import logging
from .locale_pipeline import SUPPORTED_LOCALES, LocalePipeline, ProcessResult, TrainEntry
from .training_data import entries_for_locale
logger = logging.getLogger(__name__)
class PipelineRegistry:
def __init__(self) -> None:
self._pipelines: dict[str, LocalePipeline] = {
locale: LocalePipeline(locale) for locale in SUPPORTED_LOCALES
}
def initialize(self) -> None:
"""Charge le modèle spaCy de base *et* entraîne chaque locale connue
depuis `training_data.TECH_STEP_TRAINING_DATA` appelé une fois au
démarrage du process (`main.py`'s `lifespan`), avant que `uvicorn`
n'accepte de requêtes.
Contrairement à la version précédente de ce service ( `apps/api`
poussait le corpus via `POST /v1/train` à son propre warm-up), ce
service est maintenant entièrement autonome : `apps/api` ne connaît
plus aucune technique, seulement le résultat de
`POST /v1/process`. `GET /health` ne répond `200` qu'une fois cette
méthode terminée (chargement *et* entraînement) pas seulement le
chargement pour que `docker-compose.yml`'s `depends_on: ...
condition: service_healthy` (et la boucle d'attente équivalente en
CI) ne laisse jamais `apps/api` démarrer face à un service qui
répondrait mais ne saurait encore rien détecter.
"""
logger.info("tech-step NLP initializing pipelines", extra={"locales": list(self._pipelines)})
for locale, pipeline in self._pipelines.items():
pipeline.preload()
entries = [TrainEntry(**entry) for entry in entries_for_locale(locale)]
label_count, example_count, synonym_count = pipeline.train(entries)
logger.info(
"tech-step NLP pipeline trained",
extra={
"locale": locale,
"labelCount": label_count,
# Nombre réel d'exemples donnés au textcat (utterances
# *et* synonyms combinés — voir `LocalePipeline.train`),
# pas seulement le compte d'`utterances` du corpus.
"exampleCount": example_count,
"synonymCount": synonym_count,
},
)
logger.info("tech-step NLP pipelines ready", extra={"locales": list(self._pipelines)})
def process(self, locale: str, text: str) -> ProcessResult:
pipeline = self._pipelines.get(locale)
if pipeline is None:
# Une locale que ce service ne sait structurellement pas
# charger (pas de modèle spaCy connu) se comporte comme une
# locale "jamais entraînée" côté `process` — reproduit le test
# `apps/api` existant ("returns an empty sequence for a locale
# nothing was trained on"), qui ne distingue pas les deux cas.
return ProcessResult(entities=[], intent=None, score=0.0)
return pipeline.process(text)
registry = PipelineRegistry()

View file

@ -1,18 +0,0 @@
"""`GET /health` — sondé par le `healthcheck` Docker (`docker-compose.yml`)
et par l'étape CI qui attend que ce service soit prêt avant de lancer la
suite Mocha de `apps/api` (voir `.github/workflows/ci.yml`). Volontairement
sans authentification, même posture que le `GET /health` existant côté
`apps/api` (`app.ts`) un healthcheck qui exigerait un secret compliquerait
sa configuration pour un gain de sécurité nul (il ne renvoie aucune donnée).
"""
from fastapi import APIRouter
from ..schemas import HealthResponse
router = APIRouter()
@router.get("/health", response_model=HealthResponse)
def health() -> HealthResponse:
return HealthResponse(status="ok")

View file

@ -1,49 +0,0 @@
"""`POST /v1/process` — appelé par `apps/api` (`IntentServiceClient.process`)
en remplacement direct de l'ancien `NlpManager.process(locale, text)`. Voir
`LocalePipeline.process` pour la sémantique exacte (locale non entraînée ou
`text` vide -> résultat vide, jamais une erreur).
"""
import logging
from fastapi import APIRouter, Depends
from ..pipeline_registry import registry
from ..schemas import EntityPayload, ProcessRequest, ProcessResponse
from ..security import require_valid_secret
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_valid_secret)])
@router.post("/v1/process", response_model=ProcessResponse)
def process(request: ProcessRequest) -> ProcessResponse:
result = registry.process(request.locale, request.text)
# Une ligne par appel — input (`locale`/`text`) et output (`entities`/
# `intent`/`score`) réunis dans la même ligne JSON, pour pouvoir suivre
# exactement ce que le pipeline a décidé pour un texte donné (voir
# `logging_config.py` pour le format).
logger.info(
"tech-step NLP process",
extra={
"locale": request.locale,
"text": request.text,
"entities": [
{"uid": entity.uid, "start": entity.start, "end": entity.end, "kind": entity.kind}
for entity in result.entities
],
"intent": result.intent,
"score": result.score,
},
)
return ProcessResponse(
entities=[
EntityPayload(uid=entity.uid, start=entity.start, end=entity.end, kind=entity.kind)
for entity in result.entities
],
intent=result.intent,
score=result.score,
)

View file

@ -1,54 +0,0 @@
"""Modèles Pydantic du contrat HTTP — voir le plan de migration pour le
contrat exact attendu côté `apps/api` (`IntentServiceClient`,
`apps/api/src/lib/recipe-matching/intent-service-client.ts`).
Pas de `POST /v1/train` ici ce service s'entraîne lui-même au démarrage
depuis `training_data.py` (voir `pipeline_registry.py`/`main.py`), plus
besoin d'un contrat HTTP pour ça.
"""
from typing import Literal
from pydantic import BaseModel
# ---------------------------------------------------------------------------
# POST /v1/process
# ---------------------------------------------------------------------------
class ProcessRequest(BaseModel):
locale: str
text: str
class EntityPayload(BaseModel):
"""Une mention candidate — technique ou ustensile, voir `kind` — offsets
caractère `[start, end)` dans `text`, convention identique à
`String.prototype.slice` côté `apps/api` (pas de décalage `+1` à
appliquer côté Node, contrairement à l'ancien `NlpManager` de
node-nlp).
`kind` distingue de quel `PhraseMatcher` la mention vient (voir
`locale_pipeline.py`'s `Entity`) — `apps/api`'s `tech-step-matcher.ts`
en a besoin pour savoir laquelle des deux résoudre (`TechStep.key` vs
`Utensil.key`)."""
uid: str
start: int
end: int
kind: Literal["technique", "utensil"] = "technique"
class ProcessResponse(BaseModel):
entities: list[EntityPayload]
intent: str | None
score: float
# ---------------------------------------------------------------------------
# GET /health
# ---------------------------------------------------------------------------
class HealthResponse(BaseModel):
status: str

View file

@ -1,35 +0,0 @@
"""Authentification des appels entrants — miroir inversé de `requireInternalWorker`
(`apps/api/src/middlewares/require-internal-worker.ts`) : ici c'est
`apps/api` qui appelle *ce* service, donc c'est ce service qui vérifie le
secret plutôt que de l'envoyer.
Comparaison à temps constant (`hmac.compare_digest`, l'équivalent Python du
`timingSafeEqual` de Node utilisé côté `apps/api`) même raisonnement :
un attaquant ne doit rien apprendre de la durée de la comparaison au-delà de
ce qu'une différence de longueur révèle déjà.
"""
import hmac
from fastapi import Header, HTTPException, status
from .config import settings
_SECRET_HEADER_NAME = "x-intent-service-secret"
def require_valid_secret(
x_intent_service_secret: str | None = Header(default=None, alias=_SECRET_HEADER_NAME),
) -> None:
"""Dépendance FastAPI montée sur chaque route protégée (`/v1/*`) — pas
`GET /health`, sondé par le healthcheck Docker sans configuration
d'auth propre.
`settings.intent_service_secret` est garanti non vide par `config.py`
(pas de valeur par défaut dans `Settings`) le seul cas à traiter ici
est un header manquant ou incorrect côté appelant.
"""
if x_intent_service_secret is None or not hmac.compare_digest(
x_intent_service_secret, settings.intent_service_secret
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")

View file

@ -1,32 +0,0 @@
"""Port Python de `normalizeText` (`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`).
Doit rester bit-pour-bit équivalent à sa contrepartie TypeScript c'est ce
qui garantit qu'un synonyme matché ici tombe exactement sur les mêmes
positions caractère que ce que `apps/api` attendait de node-nlp (voir
`LocalePipeline`'s `diacritics_normalizer`, qui applique cette fonction aux
patterns *et* au texte cible pour les faire matcher identiquement).
TypeScript original :
const COMBINING_DIACRITICS_PATTERN = /\\p{Diacritic}/gu;
export function normalizeText(text: string): string {
return text.normalize("NFD").replace(COMBINING_DIACRITICS_PATTERN, "").toLowerCase();
}
`unicodedata.combining(ch) != 0` (catégories Unicode Mn/Mc, la classe de
combinaison canonique) est l'idiome Python standard pour "strip accents
after NFD" — légèrement plus étroit que `\\p{Diacritic}` en théorie (qui
couvre aussi quelques diacritiques autonomes hors caractères combinants),
mais strictement équivalent pour tout caractère latin accentué usuel
(français/anglais) une fois décomposé en NFD, ce qui est le seul cas
réellement exercé par ce corpus.
"""
import unicodedata
def normalize_text(text: str) -> str:
"""Décompose en NFD, retire les marques combinantes (accents), met en minuscule."""
decomposed = unicodedata.normalize("NFD", text)
stripped = "".join(char for char in decomposed if not unicodedata.combining(char))
return stripped.lower()

View file

@ -1,197 +0,0 @@
"""Vocabulaire du `PhraseMatcher` d'ustensiles — contrairement à
`training_data.py`, ce catalogue n'a jamais existé côté `apps/api` avant ce
service : il est ** ici, pas rapatrié depuis TypeScript. Chaque `uid`
ci-dessous doit avoir une entrée `UTENSILS` correspondante
(`reference-seed-data.ts` côté `apps/api`) et un libellé
`catalog.utensils.<uid>` (`apps/web`'s `locales/fr/translation.json`).
Un seul type de contenu par ustensile/locale (contrairement à
`TechStepTrainingEntry`'s `synonyms`/`utterances`) : un ustensile mentionné
n'a pas besoin d'être *interprété* comme une technique peut l'être
(`préchauffer` vs `chauffer` dépend du contexte ; `poêle` n'en dépend pas) —
juste reconnu, comme les `synonyms` de `training_data.py` alimentent le
`PhraseMatcher` de techniques. Pas de `textcat` équivalent ici, voir
`LocalePipeline`'s propre commentaire sur `_utensil_matcher`.
"""
from dataclasses import dataclass, field
@dataclass(frozen=True)
class UtensilLocaleVocabulary:
synonyms: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class UtensilEntry:
"""`uid` doit correspondre à un `Utensil.key`."""
uid: str
fr: UtensilLocaleVocabulary
en: UtensilLocaleVocabulary
UTENSIL_VOCABULARY: list[UtensilEntry] = [
UtensilEntry(
uid="pan",
fr=UtensilLocaleVocabulary(synonyms=["poêle", "sauteuse", "poêle antiadhésive"]),
en=UtensilLocaleVocabulary(synonyms=["pan", "frying pan", "skillet"]),
),
UtensilEntry(
uid="saucepan",
fr=UtensilLocaleVocabulary(synonyms=["casserole", "petite casserole"]),
en=UtensilLocaleVocabulary(synonyms=["saucepan", "sauce pan"]),
),
UtensilEntry(
uid="pot",
fr=UtensilLocaleVocabulary(synonyms=["marmite", "faitout", "cocotte"]),
en=UtensilLocaleVocabulary(synonyms=["pot", "stockpot", "dutch oven"]),
),
UtensilEntry(
uid="knife",
fr=UtensilLocaleVocabulary(synonyms=["couteau", "couteau de cuisine", "couteau d'office"]),
en=UtensilLocaleVocabulary(synonyms=["knife", "kitchen knife", "chef's knife"]),
),
UtensilEntry(
uid="whisk",
fr=UtensilLocaleVocabulary(synonyms=["fouet"]),
en=UtensilLocaleVocabulary(synonyms=["whisk"]),
),
UtensilEntry(
uid="bowl",
fr=UtensilLocaleVocabulary(synonyms=["saladier", "bol", "cul-de-poule"]),
en=UtensilLocaleVocabulary(synonyms=["bowl", "mixing bowl"]),
),
UtensilEntry(
uid="bakingSheet",
fr=UtensilLocaleVocabulary(synonyms=["plaque de cuisson", "plaque à pâtisserie", "plaque du four"]),
en=UtensilLocaleVocabulary(synonyms=["baking sheet", "baking tray", "sheet pan"]),
),
UtensilEntry(
uid="mold",
fr=UtensilLocaleVocabulary(synonyms=["moule", "moule à gâteau", "moule à cake"]),
en=UtensilLocaleVocabulary(synonyms=["mold", "mould", "baking pan"]),
),
UtensilEntry(
uid="colander",
fr=UtensilLocaleVocabulary(synonyms=["passoire", "égouttoir"]),
en=UtensilLocaleVocabulary(synonyms=["colander", "strainer"]),
),
UtensilEntry(
uid="cuttingBoard",
fr=UtensilLocaleVocabulary(synonyms=["planche à découper"]),
en=UtensilLocaleVocabulary(synonyms=["cutting board", "chopping board"]),
),
UtensilEntry(
uid="oven",
fr=UtensilLocaleVocabulary(synonyms=["four"]),
en=UtensilLocaleVocabulary(synonyms=["oven"]),
),
UtensilEntry(
uid="blender",
fr=UtensilLocaleVocabulary(synonyms=["blender", "mixeur plongeant", "mixeur girafe"]),
en=UtensilLocaleVocabulary(synonyms=["blender", "immersion blender"]),
),
UtensilEntry(
uid="mixer",
fr=UtensilLocaleVocabulary(synonyms=["batteur", "batteur électrique", "robot pâtissier"]),
en=UtensilLocaleVocabulary(synonyms=["mixer", "stand mixer", "hand mixer"]),
),
UtensilEntry(
uid="spatula",
fr=UtensilLocaleVocabulary(synonyms=["spatule", "maryse"]),
en=UtensilLocaleVocabulary(synonyms=["spatula"]),
),
UtensilEntry(
uid="ladle",
fr=UtensilLocaleVocabulary(synonyms=["louche"]),
en=UtensilLocaleVocabulary(synonyms=["ladle"]),
),
UtensilEntry(
uid="grater",
fr=UtensilLocaleVocabulary(synonyms=["râpe"]),
en=UtensilLocaleVocabulary(synonyms=["grater"]),
),
UtensilEntry(
uid="rollingPin",
fr=UtensilLocaleVocabulary(synonyms=["rouleau à pâtisserie"]),
en=UtensilLocaleVocabulary(synonyms=["rolling pin"]),
),
UtensilEntry(
uid="lid",
fr=UtensilLocaleVocabulary(synonyms=["couvercle"]),
en=UtensilLocaleVocabulary(synonyms=["lid"]),
),
UtensilEntry(
uid="tongs",
fr=UtensilLocaleVocabulary(synonyms=["pince", "pince de cuisine"]),
en=UtensilLocaleVocabulary(synonyms=["tongs"]),
),
UtensilEntry(
uid="peeler",
fr=UtensilLocaleVocabulary(synonyms=["économe", "éplucheur"]),
en=UtensilLocaleVocabulary(synonyms=["peeler", "vegetable peeler"]),
),
UtensilEntry(
uid="sieve",
fr=UtensilLocaleVocabulary(synonyms=["tamis", "chinois"]),
en=UtensilLocaleVocabulary(synonyms=["sieve"]),
),
UtensilEntry(
uid="foodProcessor",
fr=UtensilLocaleVocabulary(synonyms=["robot ménager", "robot de cuisine", "robot culinaire"]),
en=UtensilLocaleVocabulary(synonyms=["food processor"]),
),
UtensilEntry(
uid="steamerBasket",
fr=UtensilLocaleVocabulary(synonyms=["panier vapeur", "cuit-vapeur"]),
en=UtensilLocaleVocabulary(synonyms=["steamer basket", "steamer"]),
),
UtensilEntry(
uid="skewer",
fr=UtensilLocaleVocabulary(synonyms=["brochette", "pique en bois"]),
en=UtensilLocaleVocabulary(synonyms=["skewer"]),
),
UtensilEntry(
uid="pastryBrush",
fr=UtensilLocaleVocabulary(synonyms=["pinceau de cuisine", "pinceau à pâtisserie"]),
en=UtensilLocaleVocabulary(synonyms=["pastry brush", "basting brush"]),
),
UtensilEntry(
uid="ramekin",
fr=UtensilLocaleVocabulary(synonyms=["ramequin"]),
en=UtensilLocaleVocabulary(synonyms=["ramekin"]),
),
UtensilEntry(
uid="dish",
fr=UtensilLocaleVocabulary(synonyms=["plat", "plat à gratin", "plat allant au four"]),
en=UtensilLocaleVocabulary(synonyms=["dish", "baking dish", "gratin dish"]),
),
UtensilEntry(
uid="wok",
fr=UtensilLocaleVocabulary(synonyms=["wok"]),
en=UtensilLocaleVocabulary(synonyms=["wok"]),
),
UtensilEntry(
uid="thermometer",
fr=UtensilLocaleVocabulary(synonyms=["thermomètre", "thermomètre de cuisson"]),
en=UtensilLocaleVocabulary(synonyms=["thermometer"]),
),
UtensilEntry(
uid="mandoline",
fr=UtensilLocaleVocabulary(synonyms=["mandoline"]),
en=UtensilLocaleVocabulary(synonyms=["mandoline"]),
),
]
def synonyms_for_locale(locale: str) -> dict[str, list[str]]:
"""Aplati {@link UTENSIL_VOCABULARY} en `{uid: synonyms}` pour une seule
locale la forme que `LocalePipeline.preload()` attend pour construire
son `PhraseMatcher` d'ustensiles. Miroir de `training_data.entries_for_locale`,
en plus simple (pas d'`utterances`, un seul champ à extraire)."""
return {
entry.uid: getattr(entry, locale).synonyms
for entry in UTENSIL_VOCABULARY
if hasattr(entry, locale)
}

View file

@ -1,53 +0,0 @@
[project]
name = "tech-step-intent-service"
version = "0.1.0"
description = "Microservice de détection d'intention (technique de cuisine) — remplace node-nlp côté apps/api."
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115,<0.116",
"uvicorn[standard]>=0.32,<0.33",
"spacy>=3.8,<3.9",
# Fournit les tables de lookup ("lexeme_norm" notamment) que
# `nlp.initialize()` réclame pour l'anglais lors de l'entraînement du
# textcat (`en_core_web_md` ne les embarque pas lui-même, contrairement à
# `fr_core_news_md`) — sans ce paquet, entraîner un pipeline "en" lève
# `E955`.
"spacy-lookups-data>=1.0,<1.1",
"pydantic-settings>=2.6,<3",
# Modèles spaCy installés comme des dépendances pip normales, pinnées par
# URL de release GitHub (pas via `python -m spacy download`, qui résout
# "la dernière version compatible" et n'est pas verrouillable par
# `uv.lock`). `uv sync --frozen` installe donc déjà les modèles — aucune
# étape `spacy download` séparée, ni au Dockerfile ni en CI. Version
# 3.8.0 choisie pour matcher la ligne spaCy 3.8 pinnée ci-dessus (voir
# https://github.com/explosion/spacy-models/releases).
"fr_core_news_md @ https://github.com/explosion/spacy-models/releases/download/fr_core_news_md-3.8.0/fr_core_news_md-3.8.0-py3-none-any.whl",
"en_core_web_md @ https://github.com/explosion/spacy-models/releases/download/en_core_web_md-3.8.0/en_core_web_md-3.8.0-py3-none-any.whl",
]
[dependency-groups]
dev = [
"pytest>=8,<9",
# Requis par fastapi.testclient.TestClient (httpx en interne depuis FastAPI 0.110+).
"httpx>=0.27,<0.28",
]
[tool.uv]
# Les deux modèles ci-dessus sont publiés comme des builds "any" universels
# (pas de wheel spécifique par plateforme) — rien à déclarer de plus ici,
# contrairement à un paquet avec des extras natifs par OS/arch.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["intent_service"]
[tool.hatch.metadata]
# Requis par hatchling pour accepter des dépendances pinnées par URL directe
# (les wheels de modèles spaCy ci-dessus) plutôt qu'un nom+version résolu
# depuis un index PyPI — voir la note sur `pyproject.toml` dans le plan de
# migration pour pourquoi ces modèles sont déclarés ainsi plutôt que via
# `python -m spacy download`.
allow-direct-references = true

View file

@ -1,31 +0,0 @@
"""`Settings` (`intent_service/config.py`) lève dès l'import si
`INTENT_SERVICE_SECRET` est absent cette variable doit donc être définie
avant le tout premier `import intent_service...` de la session pytest.
`conftest.py` est chargé par pytest avant la collecte des modules de test,
donc avant que `test_routes_process.py`/`test_security.py` n'importent
`intent_service.main`.
"""
import os
os.environ.setdefault("INTENT_SERVICE_SECRET", "pytest-only-secret-not-used-anywhere-else-32ch")
import pytest # noqa: E402 — après le `setdefault` ci-dessus, voir le docstring.
from fastapi.testclient import TestClient # noqa: E402
from intent_service.main import app # noqa: E402
@pytest.fixture(scope="session")
def client():
"""`TestClient(app)` utilisé comme gestionnaire de contexte déclenche le
vrai `lifespan` puisque `main.py`'s `lifespan` entraîne maintenant
l'intégralité du vrai corpus `training_data.TECH_STEP_TRAINING_DATA`
(pas un jeu jouet, voir `PipelineRegistry.initialize`), refaire ça une
fois par fichier de test (ou pire, une fois par test) multiplierait un
entraînement non négligeable sur toute la suite pour rien scope
"session" pour que chaque test ayant besoin d'une vraie app en cours
d'exécution partage la même instance déjà entraînée.
"""
with TestClient(app) as test_client:
yield test_client

View file

@ -1,154 +0,0 @@
"""Rejoue les cas d'offsets caractère exacts et d'insensibilité accents/casse
de `tech-step-matcher.test.ts` (`apps/api/test/recipe-matching/tech-step-matcher.test.ts`)
contre le `PhraseMatcher`/`diacritics_normalizer` de `LocalePipeline` le
point de fidélité le plus critique de cette migration (voir le plan). Doit
être vert *avant* de brancher `apps/api` dessus.
Ces tests entraînent un pipeline minimal (pas le corpus complet
`TECH_STEP_TRAINING_DATA`, propriété de `apps/api`) avec juste assez de
`synonyms`/`utterances` pour reproduire chaque cas le textcat n'est pas ce
qui est vérifié ici (voir `test_locale_pipeline_intent.py`).
"""
import pytest
from intent_service.locale_pipeline import LocalePipeline, TrainEntry
# Un jeu d'entrées minimal mais réaliste, reprenant les synonymes réels de
# `tech-step-training-data.ts` pour "preheat"/"melt" qui rendent les cas
# `tech-step-matcher.test.ts` exacts (voir ce fichier, lignes 155/787).
_FR_ENTRIES = [
TrainEntry(
uid="preheat",
synonyms=["préchauffer", "poêle chaude"],
utterances=["préchauffer le four à 180 degrés", "mettre la poêle sur feu vif"],
),
TrainEntry(
# `synonyms` deliberately includes both "fondre" (standalone) and
# "faire fondre" (containing it) — mirrors the real corpus
# (`tech-step-training-data.ts`) exactly, and is what
# `test_does_not_double_match_a_synonym_nested_in_a_longer_one`
# below exists to guard: the `PhraseMatcher` reports both as
# separate overlapping matches, `LocalePipeline.process` must
# collapse them into one.
uid="melt",
synonyms=["fondre", "fondu", "faire fondre", "faire chauffer"],
utterances=["faire fondre le beurre", "faire chauffer une noix de beurre"],
),
TrainEntry(
uid="simmer",
synonyms=["mijoter"],
utterances=["faire mijoter à feu doux", "laisser mijoter à couvert"],
),
]
@pytest.fixture(scope="module")
def fr_pipeline() -> LocalePipeline:
pipeline = LocalePipeline("fr")
pipeline.train(_FR_ENTRIES)
return pipeline
def test_matches_an_exact_expression(fr_pipeline: LocalePipeline):
result = fr_pipeline.process("Faire mijoter à feu doux")
assert [entity.uid for entity in result.entities] == ["simmer"]
entity = result.entities[0]
text = "Faire mijoter à feu doux"
assert text[entity.start : entity.end].lower() == "mijoter"
def test_is_case_and_accent_insensitive(fr_pipeline: LocalePipeline):
result = fr_pipeline.process("FAIRE MIJOTER")
assert [entity.uid for entity in result.entities] == ["simmer"]
def test_returns_no_entities_when_nothing_matches(fr_pipeline: LocalePipeline):
result = fr_pipeline.process("Ranger les couverts dans le tiroir")
assert result.entities == []
def test_returns_empty_for_an_empty_text(fr_pipeline: LocalePipeline):
result = fr_pipeline.process("")
assert result.entities == []
assert result.intent is None
assert result.score == 0.0
def test_untrained_locale_returns_empty_without_error():
pipeline = LocalePipeline("en")
result = pipeline.process("melt the butter")
assert result.entities == []
assert result.intent is None
assert result.score == 0.0
def test_detects_two_techniques_with_exact_tight_spans_reading_order(fr_pipeline: LocalePipeline):
# This text's "poêle" is now *also* a real utensil match ("pan", see
# `utensil_vocabulary.py`) — filtered out here by `kind` since this test
# is specifically about technique-candidate ordering, not the full
# mixed entity list (see `test_utensil_matching.py` for the utensil
# matcher's own coverage).
text = "Préchauffer la poêle, puis faire fondre le beurre"
result = fr_pipeline.process(text)
technique_entities = [entity for entity in result.entities if entity.kind == "technique"]
uids_by_start = sorted(((entity.start, entity.uid) for entity in technique_entities))
assert [uid for _, uid in uids_by_start] == ["preheat", "melt"]
preheat_entity = next(e for e in result.entities if e.uid == "preheat")
melt_entity = next(e for e in result.entities if e.uid == "melt")
assert text[preheat_entity.start : preheat_entity.end].lower() == "préchauffer"
assert text[melt_entity.start : melt_entity.end].lower() == "faire fondre"
def test_does_not_double_match_a_synonym_nested_in_a_longer_one(fr_pipeline: LocalePipeline):
# Regression: "fondre" is itself a substring of "faire fondre" — both
# are registered as `melt` synonyms (like the real corpus). Without
# `filter_spans` in `LocalePipeline.process`, the `PhraseMatcher`
# reports *both* overlapping matches, producing `melt` twice in
# apps/api's final `matchTechSteps` output instead of once (caught by a
# real CI failure in `tech-step-matcher.test.ts` once this service
# replaced node-nlp).
text = "faire fondre le beurre"
result = fr_pipeline.process(text)
assert [entity.uid for entity in result.entities] == ["melt"]
entity = result.entities[0]
assert text[entity.start : entity.end] == "faire fondre"
def test_matches_the_classic_poele_chaude_example_with_exact_offsets(fr_pipeline: LocalePipeline):
# Le cas motivant les context spans côté apps/api (tech-step-matcher.test.ts) :
# le mot-clé de `preheat` est un groupe nominal ("poêle chaude"), pas un
# verbe. Offsets attendus IDENTIQUES à ceux du test TS d'origine :
# preheat -> [9, 21) ("poêle chaude"), melt -> [23, 37) ("faire chauffer").
text = "Dans une poêle chaude, faire chauffer une noix de beurre"
result = fr_pipeline.process(text)
preheat_entity = next(e for e in result.entities if e.uid == "preheat")
melt_entity = next(e for e in result.entities if e.uid == "melt")
assert (preheat_entity.start, preheat_entity.end) == (9, 21)
assert text[preheat_entity.start : preheat_entity.end] == "poêle chaude"
assert (melt_entity.start, melt_entity.end) == (23, 37)
assert text[melt_entity.start : melt_entity.end] == "faire chauffer"
def test_chop_matches_english_text_tight_span():
pipeline = LocalePipeline("en")
pipeline.train(
[
TrainEntry(
uid="chop",
synonyms=["chop"],
utterances=["chop the onions finely", "finely chop the garlic"],
),
TrainEntry(uid="boil", synonyms=["boil"], utterances=["bring to the boil", "boil the water"]),
]
)
text = "Chop the onions finely"
result = pipeline.process(text)
chop_entity = next(e for e in result.entities if e.uid == "chop")
assert (chop_entity.start, chop_entity.end) == (0, 4)
assert text[chop_entity.start : chop_entity.end] == "Chop"

View file

@ -1,80 +0,0 @@
"""Vérifie le round-trip entraînement -> prédiction du `textcat` (la partie
"comprendre le sens, pas juste les mots clés" du pipeline voir le
commentaire de `tech-step-matcher.ts` côté apps/api pour la motivation
d'origine)."""
from intent_service.locale_pipeline import LocalePipeline, TrainEntry
_ENTRIES = [
TrainEntry(
uid="melt",
synonyms=["faire fondre"],
utterances=[
"faire fondre le beurre à feu doux",
"laisser fondre le beurre dans la poêle",
"jusqu'à ce que le beurre ait disparu",
"jusqu'à ce que le beurre ait complètement disparu dans la poêle",
],
),
TrainEntry(
uid="boil",
synonyms=["bouillir"],
utterances=[
"porter l'eau à ébullition",
"faire bouillir l'eau salée",
"laisser bouillir quelques minutes",
"porter à ébullition puis baisser le feu",
],
),
]
def test_train_returns_label_example_and_synonym_counts():
pipeline = LocalePipeline("fr")
label_count, example_count, synonym_count = pipeline.train(_ENTRIES)
assert label_count == 2
# `example_count` couvre les utterances *et* les synonyms (voir
# LocalePipeline.train — les synonymes sont aussi des exemples
# d'entraînement pour le textcat, pas seulement pour le PhraseMatcher).
expected_examples = sum(len(entry.utterances) + len(entry.synonyms) for entry in _ENTRIES)
assert example_count == expected_examples
assert synonym_count == sum(len(entry.synonyms) for entry in _ENTRIES)
assert pipeline.is_trained is True
def test_classifies_a_paraphrase_never_using_the_techniques_own_verb():
# Le cas motivant tout le pipeline (voir tech-step-matcher.ts) : aucune
# forme de "fondre" dans cette phrase, mais elle ne peut raisonnablement
# signifier que `melt` une fois le textcat entraîné sur les paraphrases
# ci-dessus.
pipeline = LocalePipeline("fr")
pipeline.train(_ENTRIES)
result = pipeline.process("jusqu'à ce que le beurre ait disparu dans la poêle")
assert result.intent == "melt"
assert result.score > 0.5
def test_empty_entries_leaves_the_pipeline_untrained():
pipeline = LocalePipeline("fr")
pipeline.train([])
assert pipeline.is_trained is False
result = pipeline.process("faire fondre le beurre")
assert result.intent is None
assert result.entities == []
def test_retraining_replaces_the_previous_textcat_rather_than_accumulating():
# `textcat` (exclusive_classes) exige >= 2 labels (voir la note dans
# LocalePipeline.train) — le second entraînement garde donc 2 entrées,
# mais remplace "boil" par une technique différente ("chop"), pour
# vérifier que "boil" ne peut plus jamais ressortir après coup (pas de
# fusion incrémentale — voir la doc de `LocalePipeline.train`).
pipeline = LocalePipeline("fr")
pipeline.train(_ENTRIES)
chop_entry = TrainEntry(uid="chop", synonyms=["couper"], utterances=["couper les légumes en dés"])
pipeline.train([_ENTRIES[0], chop_entry])
result = pipeline.process("porter l'eau à ébullition")
assert result.intent != "boil"

View file

@ -1,57 +0,0 @@
"""Vérifie le format des lignes de log produites par
`logging_config._JsonFormatter` ce que `routes/process.py` et
`pipeline_registry.py` utilisent pour journaliser l'input/l'output de
chaque appel NLP et le déroulement de l'entraînement au démarrage."""
import json
import logging
from intent_service.logging_config import _JsonFormatter
def _make_record(**extra: object) -> logging.LogRecord:
record = logging.LogRecord(
name="intent_service.routes.process",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg="tech-step NLP process",
args=(),
exc_info=None,
)
for key, value in extra.items():
setattr(record, key, value)
return record
def test_formats_a_record_as_json_with_timestamp_level_and_message():
record = _make_record()
payload = json.loads(_JsonFormatter().format(record))
assert payload["message"] == "tech-step NLP process"
assert payload["level"] == "info"
assert "timestamp" in payload
def test_merges_extra_fields_into_the_top_level_payload():
record = _make_record(
locale="fr",
text="faire fondre le beurre",
entities=[{"uid": "melt", "start": 0, "end": 12}],
intent="melt",
score=0.93,
)
payload = json.loads(_JsonFormatter().format(record))
assert payload["locale"] == "fr"
assert payload["text"] == "faire fondre le beurre"
assert payload["entities"] == [{"uid": "melt", "start": 0, "end": 12}]
assert payload["intent"] == "melt"
assert payload["score"] == 0.93
def test_preserves_accented_characters_literally_not_escaped():
# `ensure_ascii=False` — un `docker logs` humain doit pouvoir lire
# directement "poêle", pas "poêle".
record = _make_record(text="Dans une poêle chaude")
line = _JsonFormatter().format(record)
assert "poêle" in line
assert "\\u00ea" not in line

View file

@ -1,51 +0,0 @@
"""Contrat JSON de `POST /v1/process` — voir `schemas.py`/`routes/process.py`.
Le service s'entraîne désormais lui-même au démarrage sur le vrai corpus
(`training_data.TECH_STEP_TRAINING_DATA`, voir `conftest.py`'s fixture
`client` partagée) ces tests vérifient donc le contrat HTTP contre des
phrases réelles du corpus, plus besoin d'un `POST /v1/train` préalable avec
des données jouets.
"""
from fastapi.testclient import TestClient
from intent_service.config import settings
_HEADERS = {"X-Intent-Service-Secret": settings.intent_service_secret}
def test_process_against_an_unsupported_locale_returns_empty_result(client: TestClient):
# "de" n'a aucun modèle spaCy connu (`SUPPORTED_LOCALES`) — se comporte
# comme "jamais entraîné" côté `/v1/process`, jamais une erreur (voir
# `PipelineRegistry.process`).
response = client.post(
"/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}
def test_process_returns_entities_and_intent_for_a_real_corpus_sentence(client: TestClient):
response = client.post(
"/v1/process", headers=_HEADERS, json={"locale": "fr", "text": "Faire mijoter à feu doux"}
)
assert response.status_code == 200
body = response.json()
assert body["intent"] == "simmer"
assert body["score"] > 0
assert [entity["uid"] for entity in body["entities"]] == ["simmer"]
def test_process_matches_english_text_against_the_english_trained_vocabulary(client: TestClient):
response = client.post(
"/v1/process", headers=_HEADERS, json={"locale": "en", "text": "Chop the onions finely"}
)
assert response.status_code == 200
body = response.json()
assert [entity["uid"] for entity in body["entities"]] == ["chop"]
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}

View file

@ -1,37 +0,0 @@
"""`require_valid_secret` — miroir inversé de
`require-internal-worker.test.ts` côté `apps/api`. Utilise la fixture
`client` partagée (`conftest.py`) pas besoin d'une app entraînée
séparément juste pour tester l'authentification.
"""
from fastapi.testclient import TestClient
from intent_service.config import settings
def test_rejects_a_missing_secret(client: TestClient):
response = client.post("/v1/process", json={"locale": "fr", "text": "faire fondre"})
assert response.status_code == 401
def test_rejects_a_wrong_secret(client: TestClient):
response = client.post(
"/v1/process",
json={"locale": "fr", "text": "faire fondre"},
headers={"X-Intent-Service-Secret": "not-the-right-secret"},
)
assert response.status_code == 401
def test_accepts_the_configured_secret(client: TestClient):
response = client.post(
"/v1/process",
json={"locale": "fr", "text": "faire fondre"},
headers={"X-Intent-Service-Secret": settings.intent_service_secret},
)
assert response.status_code == 200
def test_health_requires_no_secret(client: TestClient):
response = client.get("/health")
assert response.status_code == 200

View file

@ -1,21 +0,0 @@
"""Réplique les cas de `normalizeText` de `tech-step-matcher.test.ts`
(`apps/api/test/recipe-matching/tech-step-matcher.test.ts`) contre le port
Python les deux fonctions doivent rester bit-pour-bit équivalentes."""
from intent_service.text_normalization import normalize_text
def test_lowercases_and_strips_accents():
assert normalize_text("Déglacer AU FOUR") == "deglacer au four"
def test_strips_a_variety_of_diacritics_including_cedilla():
assert normalize_text("Façon Œuf à l'Étouffée") == "facon œuf a l'etouffee"
def test_leaves_already_plain_text_unchanged_aside_from_casing():
assert normalize_text("Mix everything") == "mix everything"
def test_returns_an_empty_string_for_an_empty_input():
assert normalize_text("") == ""

View file

@ -1,22 +0,0 @@
"""Garde-fou de non-régression pour l'équilibrage du corpus (voir
`training_data.py`'s propre commentaire de tête) : chaque technique doit
avoir exactement le même nombre d'`utterances` que chaque autre, par
locale un déséquilibre entre classes est une source réelle de
classifications confiantes mais fausses sur une phrase jamais vue (constaté
en pratique voir l'historique Git de ce fichier, trois tentatives
d'équilibrer vers un nombre plus élevé ont toutes dégradé le F1 agrégé de
`test/recipe-matching/tech-step-eval.test.ts` avant que la stratégie
actuelle équilibrer vers le maximum déjà présent dans le corpus, pas un
nombre choisi dans l'absolu — ne passe cette même gate)."""
from intent_service.training_data import TECH_STEP_TRAINING_DATA
def test_every_technique_has_the_same_utterance_count_per_locale():
for locale in ("fr", "en"):
counts = {entry.uid: len(getattr(entry, locale).utterances) for entry in TECH_STEP_TRAINING_DATA}
distinct = set(counts.values())
assert len(distinct) == 1, (
f"utterance counts for locale {locale!r} aren't uniform across techniques "
f"(run augment_utterances.py to re-equalize): {counts}"
)

View file

@ -1,78 +0,0 @@
"""Couvre `LocalePipeline`'s second `PhraseMatcher` (ustensiles,
`utensil_vocabulary.py`) même style que `test_locale_pipeline_entities.py`
(offsets exacts, insensibilité accents/casse), mais contre le vocabulaire
*réel* (`UTENSIL_VOCABULARY`, statique, construit par `preload()` pas
besoin d'un jeu de test dédié comme pour les techniques, voir
`LocalePipeline.preload`'s own comment)."""
from intent_service.locale_pipeline import LocalePipeline, TrainEntry
# Un `train()` minimal suffit — le `PhraseMatcher` d'ustensiles est
# construit par `preload()` (appelé par `train()`), indépendamment du
# `TrainEntry` de techniques passé ici (voir `preload()`'s own comment sur
# pourquoi les deux ne sont pas couplés).
_MINIMAL_ENTRIES = [
TrainEntry(uid="melt", synonyms=["fondre"], utterances=["faire fondre le beurre"]),
TrainEntry(uid="simmer", synonyms=["mijoter"], utterances=["faire mijoter à feu doux"]),
]
def _fr_pipeline() -> LocalePipeline:
pipeline = LocalePipeline("fr")
pipeline.train(_MINIMAL_ENTRIES)
return pipeline
def test_matches_a_real_utensil_with_exact_span():
pipeline = _fr_pipeline()
text = "Dans une poêle chaude, faire fondre le beurre"
result = pipeline.process(text)
pan_entities = [e for e in result.entities if e.uid == "pan"]
assert len(pan_entities) == 1
entity = pan_entities[0]
assert entity.kind == "utensil"
assert text[entity.start : entity.end] == "poêle"
def test_is_case_and_accent_insensitive():
pipeline = _fr_pipeline()
result = pipeline.process("Verser dans la POÊLE")
utensil_uids = [e.uid for e in result.entities if e.kind == "utensil"]
assert utensil_uids == ["pan"]
def test_matches_a_multi_word_synonym():
pipeline = _fr_pipeline()
text = "Découper les légumes sur la planche à découper"
result = pipeline.process(text)
board_entities = [e for e in result.entities if e.uid == "cuttingBoard"]
assert len(board_entities) == 1
assert text[board_entities[0].start : board_entities[0].end] == "planche à découper"
def test_technique_and_utensil_are_both_returned_without_interfering():
pipeline = _fr_pipeline()
text = "Dans une casserole, faire mijoter à feu doux"
result = pipeline.process(text)
kinds_by_uid = {e.uid: e.kind for e in result.entities}
assert kinds_by_uid.get("simmer") == "technique"
assert kinds_by_uid.get("saucepan") == "utensil"
def test_returns_no_utensil_entities_when_none_are_mentioned():
pipeline = _fr_pipeline()
result = pipeline.process("Laisser reposer la pâte une heure")
assert [e for e in result.entities if e.kind == "utensil"] == []
def test_matches_english_utensils_too():
pipeline = LocalePipeline("en")
pipeline.train([TrainEntry(uid="chop", synonyms=["chop"], utterances=["chop the onions finely"])])
text = "Heat the pan before adding the onions"
result = pipeline.process(text)
pan_entities = [e for e in result.entities if e.uid == "pan"]
assert len(pan_entities) == 1
assert pan_entities[0].kind == "utensil"
assert text[pan_entities[0].start : pan_entities[0].end] == "pan"

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more