Compare commits

..

5 commits

Author SHA1 Message Date
f741bbb4f1 fix(web): retire l'affichage visuel du contexte des tech steps
Ne touche que le rendu — le backend continue de calculer et de
persister contextStart/contextEnd (tech-step-matcher.ts, StepTechStep),
et splitDescriptionByTechSteps continue de découper la description
autour du contexte (segments isKeyword: false). StepDescription.tsx
rend désormais ces segments comme du texte brut, comme un segment sans
technique — plus d'encadré/bordure autour de la clause, seul le
mot-clé reste surligné avec sa tooltip.

.step-tech-step-context (CSS) retirée, devenue inutilisée.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 18:42:29 +02:00
c190f3f21c fix(web): rend le contexte des tech steps réellement visible
Le style existant (fond teinté à 6% d'opacité, sans autre indice
visuel) était structurellement correct — vérifié en base, l'API et le
DOM contenaient bien les spans de contexte — mais imperceptible à
l'œil sur ce thème sombre : --color-primary n'est pas assez saturé
pour qu'une teinte de quelques % se distingue du fond de la carte.
Vérifié en créant une recette test dans le navigateur et en zoomant le
texte rendu : littéralement aucune différence visible avant, un
rectangle net après.

Passe à 10% de fond + une bordure basse pleine à 45% d'opacité comme
second indice visuel indépendant, tout en gardant le mot-clé
(soulignement pointillé + fond à 14% + curseur + tooltip) nettement
plus marqué que son contexte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 18:30:49 +02:00
76175bcdbf fix(api): corrige la découpe des clauses et le seuil de confiance du classifieur de tech steps
Trouvé en examinant des vraies recettes déjà en base après le dernier
étoffement du vocabulaire : plusieurs étapes bien réelles se faisaient
classer sur la mauvaise technique, sans lien avec un mot-clé manquant.

- splitIntoClauses coupe désormais sur la limite de phrase (juste après
  un ".", "!" ou "?") la plus proche du milieu de l'écart entre deux
  candidats quand il y en a une, plutôt que sur l'espace brut le plus
  proche du milieu. Une description à deux techniques dans deux phrases
  distinctes ("Préchauffer le four à 180°C. Dans un saladier, mettre le
  beurre... et mélanger.") ne coupait qu'au milieu brut, ce qui pouvait
  trancher en pleine deuxième phrase et envoyer au classifieur une
  clause tronquée ("...(thermostat 6). Dans un saladier, mettre" sans
  complément) — assez éloignée des phrases d'entraînement courtes et
  complètes pour se faire mal classer avec confiance (préchauffer prédit
  "mix", mélanger prédit "melt").
- CONFIDENCE_THRESHOLD passe de 0.65 à 0.75 : du texte anglais passé
  dans le classifieur français (qui doit ne rien trouver, garanti par
  le test d'isolation des locales) scorait 0.69 sur "boil" — du bruit
  de petit corpus, pas un vrai verdict. Les cas réels que ce seuil sert
  à faire confiance scorent 0.91 à 1.0 en pratique ; 0.75 sépare
  proprement le bruit du signal sans rien casser (309 tests toujours
  verts).
- Deux phrases d'entraînement ajoutées à `cook` pour deux clauses
  réelles mal classées (feu doux + remuant, découvert + laisser cuire)
  qui n'avaient pourtant pas de mot-clé manquant.

Ajoute aussi src/scripts/backfill-tech-steps.ts : la détection ne
tourne qu'à la création/modification d'une recette, jamais
rétroactivement — ce script recalcule le start/end/contextStart/
contextEnd de chaque étape existante contre le classifieur actuel,
pour ne pas avoir à rouvrir et resauvegarder chaque recette à la main
après un changement de corpus.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 18:15:07 +02:00
8d741ed13f feat(api): ajoute la délimitation de contexte aux tech steps et étoffe le vocabulaire du classifieur
Deux évolutions du pipeline NLP de détection des tech steps (PR #63) :

1. Délimitation de contexte — en plus du mot-clé qui déclenche un match
   (start/end), chaque TechStepMatch porte maintenant contextStart/
   contextEnd : la clause complète autour du mot-clé (ex : "poêle chaude"
   comme mot-clé, "Dans une poêle chaude" comme contexte). Persisté sur
   StepTechStep (colonnes nullables, migration dédiée), exposé via
   StepTechStepView, et rendu côté web avec un style plus discret que le
   mot-clé (StepDescription.tsx, .step-tech-step-context). splitIntoClauses
   coupe désormais sur l'espace le plus proche du milieu de l'écart entre
   deux candidats plutôt que sur le milieu brut, pour ne jamais couper un
   mot en deux (findGapSplitPoint).

2. Vocabulaire du classifieur — synonymes et locutions supplémentaires par
   technique (FR/EN) pour fiabiliser la détection sur des formulations que
   le corpus initial ne couvrait pas. Plusieurs bugs de fond trouvés et
   corrigés en cours de route, tous confirmés par la suite de tests
   complète (309 tests) :
   - un synonyme multi-mots qui est un préfixe-mot d'un synonyme plus court
     déjà enregistré pour la même technique fait matcher les deux comme
     candidats NER distincts et chevauchants, corrompant le découpage en
     clauses (parfois jusqu'à une mauvaise classification) — retiré
     partout où ce motif a été repéré (cook, fry, deglaze, simmer, boil,
     roast, chop, mince, marinate, preheat, bake, plate, coat) ;
   - "poêlé"/"poêlée" comme synonymes de panFry sont réduits à la même
     racine que le nom "poêle" par le stemmer français de node-nlp,
     provoquant un faux positif sur toute mention nue de "poêle" (dont
     celle de preheat) — retiré ;
   - "Fouetter les blancs en neige" était mal classé en foldIn (la phrase
     d'entraînement de foldIn partage la même locution) — corrigé en
     ajoutant des phrases d'entraînement dédiées à whisk ;
   - "Émincer les tomates" est passé sous le seuil de confiance vers melt
     après l'ajout du nouveau vocabulaire ailleurs dans le corpus — corrigé
     en élargissant les phrases d'entraînement de mince à un autre légume.

Le test unitaire de splitIntoClauses avec un point de coupure obsolète
(pré-datant findGapSplitPoint) est aussi corrigé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 16:42:32 +02:00
9f68c144f3 feat(api): remplace la détection des tech steps par un pipeline NLP (node-nlp)
Le matching par regex ne généralisait jamais au-delà de son propre
vocabulaire — une étape décrivant la fonte du beurre comme "jusqu'à ce
que le beurre ait disparu dans la poêle" ne contient aucun verbe sur
lequel une regex pourrait s'ancrer, alors que le sens est sans
ambiguïté.

Nouveau pipeline en 3 étapes (TechStepClassifierService, node-nlp
4.27.0 — la 5.x est encore alpha, non retenue) :
1. NER (entités enum) trouve les mentions candidates + leur position
   exacte, à partir de listes de synonymes (tech-step-training-data.ts)
   plutôt que de regex écrites à la main. ner.threshold: 1 (exact,
   après normalisation) — le défaut à 0.8 faisait matcher "faire" (verbe
   auxiliaire omniprésent) contre "frire" par pure proximité de chaîne.
2. La description est découpée en clauses autour de ces candidats
   (splitIntoClauses, pure/testable sans modèle).
3. Le NlpManager classe chaque clause individuellement, entraîné sur
   des phrases qui n'emploient jamais le verbe de la technique — c'est
   ce qui apporte la compréhension du sens. En dessous de
   CONFIDENCE_THRESHOLD (0.65, ajusté empiriquement), retombe sur la
   technique impliquée par l'ancre NER plutôt que d'abandonner un match
   clairement ancré sur un mot-clé.

TechStepMapping (table de regex par technique/locale) supprimée —
migration 20260821130000_drop_tech_step_mapping — plus aucune table
n'est interrogée à l'exécution, les données de matching vivent en code.
TECH_STEPS (reference-seed-data.ts) simplifié en simple liste de uid,
les mappings ayant disparu.

Deux pièges trouvés en construisant ce pipeline, corrigés à la source :
- db/prisma.ts construisait PrismaClient sans importer config/env.ts —
  un run de test isolé pouvait faire gagner la course au .env interne
  de Prisma (dev) contre .env.test. Fixé en important config/env.js en
  tout premier, pour effet de bord.
- NlpManager a autoSave/autoLoad: true par défaut — persiste le modèle
  entraîné dans model.nlp et le recharge au lieu de ré-entraîner au
  prochain démarrage. Les deux désactivés explicitement (sinon un
  modèle obsolète masquerait silencieusement toute mise à jour du
  corpus/seuil) ; model.nlp ajouté au .gitignore en garde-fou.

apps/api/src/db/prisma.ts, recipe.service.ts, sources.service.ts et
recipe-translation.ts adaptés à la matching async (le classifieur
entraîné remplace le couple loadTechStepMappingRules+matchTechStepSpans
synchrone) ; server.ts appelle techStepClassifier.warmUp() avant
d'accepter du trafic (le tout premier appel réel à
NlpManager.process() charge les ressources par langue de node-nlp,
plusieurs secondes).

Vérifié : tsc --noEmit, biome check (0 erreur), build complet des 6
packages, 308 tests API (dont un test-support/reset-db.ts corrigé —
référençait encore tech_step_mapping dans son TRUNCATE).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 15:29:09 +02:00
155 changed files with 2560 additions and 20116 deletions

View file

@ -21,23 +21,3 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# over plain HTTP a Secure cookie is silently never sent back by the
# 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
# out/unset to run without it.
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optional — cron expression (node-cron syntax) the worker wakes up on to
# run its audit/feedback-loop jobs. Default: weekly, Sunday 03:00 — a
# provisional floor, not a calibrated value (see
# services/tech-step-llm-worker/README.md).
# TECH_STEP_WORKER_CRON=0 3 * * 0

View file

@ -15,19 +15,9 @@ env:
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:
@ -55,7 +45,7 @@ jobs:
POSTGRES_PASSWORD: ci
POSTGRES_DB: batchcooking_ci
ports:
- 5433:5432
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
@ -71,60 +61,10 @@ jobs:
node-version: 22
cache: pnpm
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v3
with:
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: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v3
with:
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:

16
.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
@ -158,8 +157,3 @@ tmp-mockups/
# IA
.claude/
# Cypress run artifacts — regenerated locally/in CI, never meant to be committed
apps/web/cypress/screenshots/
apps/web/cypress/videos/
apps/web/cypress/downloads/

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

@ -1,27 +1,14 @@
NODE_ENV=development
PORT=3000
# Match whatever you set in the root .env (POSTGRES_USER/PASSWORD/DB) —
# do not commit the real value.
DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking?schema=public"
# Required, no default on purpose — generate your own, e.g.:
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optional — defaults shown (see src/config/env.ts)
# 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.
# Generate your own the same way as JWT_SECRET above; must match the
# worker's own INTERNAL_WORKER_SECRET.
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
NODE_ENV=development
PORT=3000
# Match whatever you set in the root .env (POSTGRES_USER/PASSWORD/DB) —
# do not commit the real value.
DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking?schema=public"
# Required, no default on purpose — generate your own, e.g.:
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optional — defaults shown (see src/config/env.ts)
# JWT_EXPIRES_IN=7d
# AUTH_COOKIE_NAME=session
# CORS_ORIGIN=http://localhost:5173

View file

@ -13,17 +13,3 @@ DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking_test?sc
# Required, no default on purpose — generate your own, e.g.:
# 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.
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars

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,47 +0,0 @@
-- CreateTable
CREATE TABLE "step_tech_step_correction" (
"id" SERIAL NOT NULL,
"step_id" INTEGER NOT NULL,
"corrector_id" INTEGER NOT NULL,
"start" INTEGER NOT NULL,
"end" INTEGER NOT NULL,
"previous_tech_step_id" INTEGER,
"corrected_tech_step_id" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"consumed_at" TIMESTAMP(3),
CONSTRAINT "step_tech_step_correction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tech_step_training_suggestion" (
"id" SERIAL NOT NULL,
"tech_step_id" INTEGER NOT NULL,
"locale" TEXT NOT NULL,
"suggested_synonyms" TEXT[],
"suggested_utterances" TEXT[],
"source_type" TEXT NOT NULL,
"source_correction_id" INTEGER,
"status" TEXT NOT NULL DEFAULT 'pending',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tech_step_training_suggestion_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_step_id_fkey" FOREIGN KEY ("step_id") REFERENCES "step"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_corrector_id_fkey" FOREIGN KEY ("corrector_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_previous_tech_step_id_fkey" FOREIGN KEY ("previous_tech_step_id") REFERENCES "tech_step"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_corrected_tech_step_id_fkey" FOREIGN KEY ("corrected_tech_step_id") REFERENCES "tech_step"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tech_step_training_suggestion" ADD CONSTRAINT "tech_step_training_suggestion_tech_step_id_fkey" FOREIGN KEY ("tech_step_id") REFERENCES "tech_step"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tech_step_training_suggestion" ADD CONSTRAINT "tech_step_training_suggestion_source_correction_id_fkey" FOREIGN KEY ("source_correction_id") REFERENCES "step_tech_step_correction"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "step_tech_step" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'auto';

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

@ -121,10 +121,6 @@ model UserProfile {
/// list regardless of that real-world cardinality.
administeredHouses House[] @relation("HouseAdmin")
preferences UserPreference?
/// Tech-step corrections this profile has submitted (any profile that can
/// view a recipe may correct its tech-step matches, not just its author —
/// see `StepTechStepCorrection.correctorId`).
techStepCorrections StepTechStepCorrection[]
@@map("user_profiles")
}
@ -515,15 +511,12 @@ model Ingredient {
/// catalog's own search with this ingredient's name).
reproducible Boolean @default(false)
recipes RecipeIngredient[]
allergies IngredientAllergy[]
recipes RecipeIngredient[]
allergies IngredientAllergy[]
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
dislikedBy UserProfileDislikedIngredient[]
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[]
diets IngredientDiet[]
@@map("ingredients")
}
@ -599,13 +592,7 @@ model Unit {
type UnitType
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[]
recipeIngredients RecipeIngredient[]
@@map("unit")
}
@ -636,50 +623,22 @@ 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
steps StepTechStep[]
/// Corrections where this technique was the *previous* (possibly wrong)
/// match — see `StepTechStepCorrection.previousTechStepId`.
correctionsAsPrevious StepTechStepCorrection[] @relation("PreviousTechStep")
/// Corrections where this technique was the *corrected* (user-asserted)
/// match — see `StepTechStepCorrection.correctedTechStepId`.
correctionsAsCorrected StepTechStepCorrection[] @relation("CorrectedTechStep")
/// Training-corpus suggestions targeting this technique — see
/// `TechStepTrainingSuggestion`.
trainingSuggestions TechStepTrainingSuggestion[]
steps StepTechStep[]
@@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
@ -691,11 +650,8 @@ model Step {
picture String?
order Int
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
techSteps StepTechStep[]
/// User-submitted corrections to this step's detected techniques — see
/// `StepTechStepCorrection`.
corrections StepTechStepCorrection[]
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
techSteps StepTechStep[]
@@map("step")
}
@ -726,174 +682,18 @@ model Step {
/// techniques from scratch (`recipe.service.ts`'s `updateRecipe` deletes
/// and recreates every `Step`/`StepTechStep`, never a partial patch) —
/// graceful degradation, not a permanent gap.
///
/// `source` distinguishes a `"manual"` row — written immediately when a
/// user submits a `StepTechStepCorrection` that asserts a technique
/// (`recipe-tech-step-correction.service.ts`'s `applyManualCorrection`),
/// not just recorded as a pending suggestion — from an `"auto"` row the
/// classifier itself produced (`tech-step-matcher.ts`). Both kinds coexist
/// in the same ordered sequence; the detail view (`apps/web`) renders them
/// with a different highlight color so a viewer can tell which is which.
/// `backfillTechSteps` (`scripts/backfill-tech-steps.ts`) only ever
/// deletes/recreates `"auto"` rows — a `"manual"` row survives a
/// classifier/corpus change until a user (or a future moderation feature)
/// explicitly changes it again.
model StepTechStep {
stepId Int @map("step_id")
techStepId Int @map("tech_step_id")
stepId Int @map("step_id")
techStepId Int @map("tech_step_id")
order Int
start Int?
end Int?
contextStart Int? @map("context_start")
contextEnd Int? @map("context_end")
source String @default("auto")
contextStart Int? @map("context_start")
contextEnd Int? @map("context_end")
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[]
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
@@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:
/// `previousTechStepId` is the (possibly absent) match being corrected,
/// `correctedTechStepId` is what the user asserts instead (absent means
/// "no technique belongs here"). Both `null` at once is invalid (nothing
/// would have changed) — enforced service-side, not by the schema, same
/// posture as other cross-field invariants in this codebase (e.g.
/// `RecipeIngredientView`'s no-duplicate-ingredient check).
///
/// `start`/`end` are the user's selected `[start, end)` span within
/// `Step.description` (`String.prototype.slice` convention, same as
/// `StepTechStep`) — what they highlighted before assigning a technique to
/// it, not necessarily identical to any existing `StepTechStep` span.
///
/// Never edited/deleted once created (an audit trail of what was actually
/// submitted) — only `consumedAt` changes, stamped once
/// `services/tech-step-llm-worker` has turned this correction into a
/// `TechStepTrainingSuggestion` for a maintainer to review, so the same
/// correction isn't proposed twice on the next scheduled run.
model StepTechStepCorrection {
id Int @id @default(autoincrement())
stepId Int @map("step_id")
/// Any profile that could *view* the recipe when they submitted this, not
/// necessarily its author — see `assertRecipeVisible`,
/// `recipe.service.ts`.
correctorId Int @map("corrector_id")
start Int
end Int
previousTechStepId Int? @map("previous_tech_step_id")
correctedTechStepId Int? @map("corrected_tech_step_id")
createdAt DateTime @default(now()) @map("created_at")
consumedAt DateTime? @map("consumed_at")
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
corrector UserProfile @relation(fields: [correctorId], references: [id], onDelete: Cascade)
previousTechStep TechStep? @relation("PreviousTechStep", fields: [previousTechStepId], references: [id], onDelete: SetNull)
correctedTechStep TechStep? @relation("CorrectedTechStep", fields: [correctedTechStepId], references: [id], onDelete: SetNull)
trainingSuggestions TechStepTrainingSuggestion[]
@@map("step_tech_step_correction")
}
/// A candidate addition to `TECH_STEP_TRAINING_DATA`
/// (`tech-step-training-data.ts`), proposed by `services/tech-step-llm-worker`
/// from one of two sources (`sourceType`):
///
/// - `"correction"` — a user's `StepTechStepCorrection`, turned into
/// suggested synonyms/utterances by the worker's LLM
/// (`transform-corrections` job).
/// - `"llm_audit"` — a low-confidence NLP clause on an *existing* recipe the
/// worker periodically samples and re-judges with its LLM
/// (`audit-low-confidence` job); no `sourceCorrectionId` in this case.
///
/// Deliberately never auto-applied to `tech-step-training-data.ts` — a
/// maintainer reviews `status: "pending"` rows (see
/// `list-pending-training-suggestions.ts`) and edits that file by hand,
/// same "generated suggestion, human-reviewed source of truth" split as a
/// linter's autofix vs. a human-authored diff. `retrain-tech-steps.ts` then
/// flips `status` to `"applied"`/`"rejected"` once a maintainer has acted on
/// a batch, so the same suggestion isn't reviewed twice.
///
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
/// (`String[]`), not a join table — unlike this schema's other list-shaped
/// data (`RecipeDiet`, `UserProfileAllergy`...), these strings are free text
/// proposed once for a human to read, not ids referencing another catalog
/// table, so there's nothing for a join table to normalize against.
model TechStepTrainingSuggestion {
id Int @id @default(autoincrement())
techStepId Int @map("tech_step_id")
locale String
suggestedSynonyms String[] @map("suggested_synonyms")
suggestedUtterances String[] @map("suggested_utterances")
sourceType String @map("source_type")
sourceCorrectionId Int? @map("source_correction_id")
status String @default("pending")
createdAt DateTime @default(now()) @map("created_at")
techStep TechStep @relation(fields: [techStepId], references: [id])
sourceCorrection StepTechStepCorrection? @relation(fields: [sourceCorrectionId], references: [id], onDelete: SetNull)
@@map("tech_step_training_suggestion")
}

View file

@ -7,13 +7,11 @@ import { errorLogger } from "./middlewares/error-logger.js";
import { requestLogger } from "./middlewares/request-logger.js";
import { authRouter } from "./modules/auth/auth.routes.js";
import { houseRouter } from "./modules/house/house.routes.js";
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
import { planningRouter } from "./modules/planning/planning.routes.js";
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";
/**
@ -40,18 +38,11 @@ export function createServer(): ExpressServer {
server.mountRouter("/auth", authRouter);
server.mountRouter("/house", houseRouter);
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
// `requireInternalWorker` on every route within (see that router's own
// doc comment), never `requireAuth`. Mounted alongside the other routers
// rather than nested under one of them since it isn't scoped to a single
// recipe/step the way `recipeRouter`'s own correction routes are.
server.mountRouter("/internal/tech-steps", techStepWorkerRouter);
server.mountRouter("/planning", planningRouter);
server.mountRouter("/preferences", preferencesRouter);
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

@ -60,36 +60,6 @@ const envSchema = z.object({
.string()
.optional()
.transform((value) => (value === undefined || value === "" ? undefined : value === "true")),
/**
* Shared secret `services/tech-step-llm-worker` sends as an
* `X-Internal-Worker-Secret` header on every call to `/internal/tech-steps/*`
* (`requireInternalWorker`, `middlewares/require-internal-worker.ts`).
* Optional with no default in the schema itself (unlike `JWT_SECRET`) so
* an environment that doesn't run the worker at all (e.g. this repo's
* existing test suite) never needs to set it but `requireInternalWorker`
* itself rejects every request outright when it's unset, so the surface
* 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

@ -1,26 +1,23 @@
import {
INGREDIENT_LABEL_SYNONYMS_EN,
INGREDIENT_LABEL_SYNONYMS_FR,
INGREDIENT_LABELS_EN,
INGREDIENT_LABELS_FR,
UNIT_LABELS_EN,
UNIT_LABELS_FR,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import { normalizeText } from "./tech-step-matcher.js";
/**
* Resolves the free-text `name`/`unit`/`quantity` a `RecipeSourceAdapter`
* lifts from a recipe source (`ParsedRecipeIngredient`) against our own
* `Ingredient`/`Unit` reference catalogs the ingredient-side counterpart
* to `tech-step-matcher.ts`'s technique detection, built for the same
* reason: a source's raw text has no idea our catalogs even exist.
* lifts from an English-language source (`ParsedRecipeIngredient`) against
* our own `Ingredient`/`Unit` reference catalogs the ingredient-side
* counterpart to `tech-step-matcher.ts`'s technique detection, built for
* the same reason: an English source's raw text has no idea our catalogs
* even exist.
*
* Unlike tech steps (regex mappings hand-authored per technique),
* ingredient/unit labels are plain hand-written text, one table per
* supported `locale` (`INGREDIENT_LABELS_EN`/`INGREDIENT_LABELS_FR`/
* `UNIT_LABELS_EN`/`UNIT_LABELS_FR`, `packages/shared`) matching them
* against arbitrary free text (extra adjectives, plurals, "large diced
* ingredient/unit labels are plain hand-written English text
* (`INGREDIENT_LABELS_EN`/`UNIT_LABELS_EN`, `packages/shared`) matching
* them against arbitrary free text (extra adjectives, plurals, "large diced
* yellow onion" for a catalog entry that's just "Onion") needs its own,
* lighter algorithm: word-tokenize both sides, naively stem for plurals,
* then look for the catalog phrase's tokens as a contiguous run inside the
@ -39,17 +36,17 @@ import { normalizeText } from "./tech-step-matcher.js";
* one-time training pass.
*/
/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its matching label in whichever locale it was loaded for. */
/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its English matching label. */
export interface IngredientMatchEntry {
ingredientId: number;
/** Matching label — English (`INGREDIENT_LABELS_EN`) or French (`INGREDIENT_LABELS_FR`) depending on which locale {@link loadIngredientCatalog} was called with, e.g. `"Chicken breast"`/`"Blanc de poulet"` — matched against free text, never displayed. */
/** English label from `INGREDIENT_LABELS_EN`, e.g. `"Chicken breast"` — matched against free text, never displayed. */
label: string;
}
/** One `Unit` row trimmed to what {@link matchUnit} needs, alongside its accepted spellings in whichever locale it was loaded for. */
/** One `Unit` row trimmed to what {@link matchUnit} needs, alongside its accepted English spellings. */
export interface UnitMatchEntry {
unitId: number;
/** Accepted spellings — English (`UNIT_LABELS_EN`) or French (`UNIT_LABELS_FR`), e.g. `["tbsp", "tbs", "tablespoon", "tablespoons"]` or `["cuillère à soupe", "cas", ...]`. Unlike English, a French entry can be genuinely multi-word — see `matchUnit`'s own doc comment. */
/** Accepted spellings from `UNIT_LABELS_EN`, e.g. `["tbsp", "tbs", "tablespoon", "tablespoons"]`. */
synonyms: string[];
}
@ -61,45 +58,19 @@ export interface UnitMatchEntry {
* of every comparison go through it, not linguistically correct on its
* own see the module doc comment.
*/
function stemWordEn(word: string): string {
function stemWord(word: string): string {
if (word.endsWith("ies") && word.length > 4) return `${word.slice(0, -3)}y`;
if (word.endsWith("es") && word.length > 3) return word.slice(0, -2);
if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1);
return word;
}
/**
* Naive French stemmer French regular plurals are overwhelmingly just
* "+s" on the singular (`"carotte"`/`"carottes"`, `"pomme"`/`"pommes"`),
* unlike English's several suffix patterns, so this only strips a single
* trailing "s". Deliberately *not* {@link stemWordEn}'s `"es"` rule reused
* here: applying it to French would silently corrupt any word whose
* singular itself ends in "e" plus a consonant before the final "s" e.g.
* `"carottes"` would wrongly stem to `"carott"` (dropping the "e" that's
* actually part of the singular `"carotte"`) instead of `"carotte"`,
* exactly the class of near-miss that made ingredient matching
* French-locale silently broken before this stemmer existed (almost every
* regular French plural ends in "es" this way it's not an edge case).
* Irregular plurals (`"cheval"`/`"chevaux"`, `"chou"`/`"choux"`) aren't
* handled same "consistent, not linguistically perfect" trade-off as
* {@link stemWordEn}.
*/
function stemWordFr(word: string): string {
if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1);
return word;
}
/** Dispatches to {@link stemWordEn}/{@link stemWordFr} by `locale` — any locale other than `"fr"` uses the English rules (the long-standing default, unchanged for every existing caller that doesn't pass a locale at all). */
function stemWord(word: string, locale: string): string {
return locale === "fr" ? stemWordFr(word) : stemWordEn(word);
}
/** Lowercases, strips diacritics (via `normalizeText`) and splits `text` into stemmed word tokens — empty/non-letter runs are dropped, not kept as empty tokens. `locale` picks the stemming rules (see {@link stemWord}); defaults to `"en"`, the original behavior every pre-existing caller still gets without passing one. */
function tokenize(text: string, locale = "en"): string[] {
/** Lowercases, strips diacritics (via `normalizeText`) and splits `text` into stemmed word tokens — empty/non-letter runs are dropped, not kept as empty tokens. */
function tokenize(text: string): string[] {
return normalizeText(text)
.split(/[^a-z]+/)
.filter((word) => word.length > 0)
.map((word) => stemWord(word, locale));
.map(stemWord);
}
/** Whether `needle` appears as a contiguous run inside `haystack`, at any starting position. */
@ -111,193 +82,22 @@ 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
* `null` if nothing matches. Among every catalog entry whose label's words
* all appear as a contiguous run in `name`, **in the same order**, the one
* with the most words wins (most specific "chicken breast" over bare
* "chicken"); ties break on the lowest `ingredientId`, for a deterministic
* result independent of catalog order. `locale` must match whatever
* `catalog`'s labels were loaded in (see {@link loadIngredientCatalog})
* defaults to `"en"`.
* all appear as a contiguous run in `name`, the one with the most words
* wins (most specific "chicken breast" over bare "chicken"); ties break
* on the lowest `ingredientId`, for a deterministic result independent of
* catalog order.
*/
export function matchIngredientName(
name: string,
catalog: IngredientMatchEntry[],
locale = "en",
): number | null {
const nameTokens = tokenize(name, locale);
export function matchIngredientName(name: string, catalog: IngredientMatchEntry[]): number | null {
const nameTokens = tokenize(name);
if (nameTokens.length === 0) return null;
let best: { ingredientId: number; tokenCount: number } | null = null;
for (const entry of catalog) {
const labelTokens = tokenize(entry.label, locale);
const labelTokens = tokenize(entry.label);
if (!containsSubsequence(nameTokens, labelTokens)) continue;
if (
best === null ||
@ -314,44 +114,24 @@ export function matchIngredientName(
}
/**
* Resolves free-text `unitText` (e.g. `"tbsp"`, `"Cups"`, `"cuillères à
* soupe de farine"`) to the best-matching `Unit` in `catalog`, or `null` if
* nothing matches. Same ordered-contiguous-run search as
* {@link matchIngredientName}, longest match wins **not** the
* single-first-word equality check this function used before French
* support existed: every English unit synonym happens to be one word, so
* comparing only `unitText`'s first token against each *whole* synonym
* string used to be enough, but a French unit can be genuinely multi-word
* (`"cuillère à soupe"`, see `UNIT_LABELS_FR`) a whole multi-word phrase
* (spaces and all) can never equal a single extracted token, so that
* approach would have silently matched nothing for any French unit
* requiring more than one word. `locale` must match whatever `catalog`'s
* synonyms were loaded in (see {@link loadUnitCatalog}) defaults to
* `"en"`.
* Resolves free-text `unitText` (e.g. `"tbsp"`, `"Cups"`) to the matching
* `Unit` in `catalog`, or `null` if nothing matches. A unit is a single
* word by convention (see `UNIT_LABELS_EN`), so this is a whole-token
* equality check (after stemming/normalizing), not the substring search
* `matchIngredientName` does `"cup"` shouldn't match inside an unrelated
* longer word.
*/
export function matchUnit(
unitText: string,
catalog: UnitMatchEntry[],
locale = "en",
): number | null {
const textTokens = tokenize(unitText, locale);
if (textTokens.length === 0) return null;
export function matchUnit(unitText: string, catalog: UnitMatchEntry[]): number | null {
const tokens = tokenize(unitText);
if (tokens.length === 0) return null;
const firstToken = tokens[0];
let best: { unitId: number; tokenCount: number } | null = null;
for (const entry of catalog) {
for (const synonym of entry.synonyms) {
const synonymTokens = tokenize(synonym, locale);
if (!containsSubsequence(textTokens, synonymTokens)) continue;
if (
best === null ||
synonymTokens.length > best.tokenCount ||
(synonymTokens.length === best.tokenCount && entry.unitId < best.unitId)
) {
best = { unitId: entry.unitId, tokenCount: synonymTokens.length };
}
if (entry.synonyms.some((synonym) => stemWord(normalizeText(synonym)) === firstToken)) {
return entry.unitId;
}
}
return best?.unitId ?? null;
return null;
}
/** What {@link extractQuantity} pulls out of a leading numeric expression, alongside what's left of the string after it. */
@ -363,10 +143,7 @@ export interface ExtractedQuantity {
// Leading "1 1/2", "1/2", "1.5", "1,5" or "2" (optionally followed by a
// hyphenated range like "2-3", in which case only the first number counts —
// good enough for a best-effort quantity, not meant to model ranges. The
// `[.,]` decimal separator already covers French recipe text ("1,5") as-is,
// same pattern used for English ("1.5") — no locale-specific handling
// needed here, unlike tokenize/stemWord above.
// good enough for a best-effort quantity, not meant to model ranges.
const LEADING_QUANTITY_PATTERN = /^(\d+)\s+(\d+)\/(\d+)|^(\d+)\/(\d+)|^(\d+(?:[.,]\d+)?)/;
/**
@ -399,42 +176,18 @@ export function extractQuantity(rawText: string): ExtractedQuantity {
return { quantity, remainder: trimmed.slice(match[0].length).trim() };
}
/**
* Per-locale matching label tables {@link loadIngredientCatalog}/
* {@link loadUnitCatalog} pick from the only two locales with any
* matching data authored yet (see `packages/shared/src/data/`). A locale
* with no entry here (anything but `"en"`/`"fr"`) falls back to empty
* tables in both loaders below the same "no matching-language data,
* degrade to doing nothing rather than guess" behavior `tech-step-matcher.ts`
* already has for a locale with no trained mappings, not a thrown error.
*/
const INGREDIENT_LABELS_BY_LOCALE: Record<string, Record<string, string>> = {
en: INGREDIENT_LABELS_EN,
fr: INGREDIENT_LABELS_FR,
};
const INGREDIENT_LABEL_SYNONYMS_BY_LOCALE: Record<string, Record<string, string[]>> = {
en: INGREDIENT_LABEL_SYNONYMS_EN,
fr: INGREDIENT_LABEL_SYNONYMS_FR,
};
const UNIT_LABELS_BY_LOCALE: Record<string, Record<string, string[]>> = {
en: UNIT_LABELS_EN,
fr: UNIT_LABELS_FR,
};
/** Loads the full `Ingredient` catalog as {@link IngredientMatchEntry}s for `locale` (default `"en"`) — one entry per key with an authored label in that locale, plus one extra entry per alternate wording (`INGREDIENT_LABEL_SYNONYMS_EN`/`_FR`, e.g. "Vanilla pod" alongside "Vanilla bean" — see issue #54) sharing the same `ingredientId`; `matchIngredientName` doesn't need to know synonyms exist, it just sees more candidate labels for the same ingredient. An ingredient with no label in `locale` yet is silently skipped, never a matching target — same for every ingredient when `locale` itself has no label table at all (see {@link INGREDIENT_LABELS_BY_LOCALE}). Meant to be fetched once per request and reused across every ingredient line, not re-queried per line. */
export async function loadIngredientCatalog(locale = "en"): Promise<IngredientMatchEntry[]> {
/** Loads the full `Ingredient` catalog as {@link IngredientMatchEntry}s — one entry per key with an authored English label (see `INGREDIENT_LABELS_EN`), plus one extra entry per alternate wording (`INGREDIENT_LABEL_SYNONYMS_EN`, e.g. "Vanilla pod" alongside "Vanilla bean" — see issue #54) sharing the same `ingredientId`; `matchIngredientName` doesn't need to know synonyms exist, it just sees more candidate labels for the same ingredient. An ingredient with no English label yet is silently skipped, never a matching target. Meant to be fetched once per request and reused across every ingredient line, not re-queried per line. */
export async function loadIngredientCatalog(): Promise<IngredientMatchEntry[]> {
try {
const labels = INGREDIENT_LABELS_BY_LOCALE[locale] ?? {};
const synonyms = INGREDIENT_LABEL_SYNONYMS_BY_LOCALE[locale] ?? {};
const ingredients = await prisma.ingredient.findMany({
select: { id: true, key: true },
});
const catalog: IngredientMatchEntry[] = [];
for (const ingredient of ingredients) {
const label = labels[ingredient.key];
const label = INGREDIENT_LABELS_EN[ingredient.key];
if (label === undefined) continue;
catalog.push({ ingredientId: ingredient.id, label });
for (const synonym of synonyms[ingredient.key] ?? []) {
for (const synonym of INGREDIENT_LABEL_SYNONYMS_EN[ingredient.key] ?? []) {
catalog.push({ ingredientId: ingredient.id, label: synonym });
}
}
@ -447,16 +200,15 @@ export async function loadIngredientCatalog(locale = "en"): Promise<IngredientMa
}
}
/** Loads the full `Unit` catalog as {@link UnitMatchEntry}s for `locale` (default `"en"`) — one entry per key with authored synonyms in that locale (see {@link UNIT_LABELS_BY_LOCALE}); a unit with none yet is silently skipped. Meant to be fetched once per request, same reasoning as {@link loadIngredientCatalog}. */
export async function loadUnitCatalog(locale = "en"): Promise<UnitMatchEntry[]> {
/** Loads the full `Unit` catalog as {@link UnitMatchEntry}s — one entry per key with authored English synonyms (see `UNIT_LABELS_EN`); a unit with none yet is silently skipped. Meant to be fetched once per request, same reasoning as {@link loadIngredientCatalog}. */
export async function loadUnitCatalog(): Promise<UnitMatchEntry[]> {
try {
const labels = UNIT_LABELS_BY_LOCALE[locale] ?? {};
const units = await prisma.unit.findMany({
select: { id: true, key: true },
});
const catalog: UnitMatchEntry[] = [];
for (const unit of units) {
const synonyms = labels[unit.key];
const synonyms = UNIT_LABELS_EN[unit.key];
if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms });
}
return catalog;

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

@ -100,19 +100,12 @@ export async function translateRecipeSteps(
}
/**
* Locale-specific label {@link matchUnit} is fed when a quantity was found
* but no unit word was see the `unitId` fallback below. Each is the
* catalog's generic "counted, no further unit" entry (`Unit.key` `"piece"`)
* in that locale's own label table (`UNIT_LABELS_EN`/`UNIT_LABELS_FR`,
* `packages/shared`). A locale with neither entry (anything but
* `"en"`/`"fr"`) falls back to the English spelling harmless, since
* `unitCatalog` itself is already empty for an unsupported locale (see
* `loadUnitCatalog`), so this fallback lookup finds nothing either way.
* English label {@link matchUnit} is fed when a quantity was found but no
* unit word was see the `unitId` fallback below. `"piece"` (`UNIT_LABELS_EN`,
* `packages/shared`) is the catalog's generic "counted, no further unit"
* entry (French "unité").
*/
const FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE: Record<string, string> = {
en: "piece",
fr: "unité",
};
const FALLBACK_COUNT_UNIT_LABEL = "piece";
/**
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
@ -134,28 +127,20 @@ const FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE: Record<string, string> = {
* button disabled with no indication why on almost any recipe with a
* whole-item ingredient. No fallback when `quantity` itself is `null`
* (e.g. `"To taste"`) there's nothing to count, so nothing to default.
*
* `locale` (default `"en"`, matching every pre-existing caller) must agree
* with whichever locale `ingredientCatalog`/`unitCatalog` were loaded in
* (see `loadIngredientCatalog`/`loadUnitCatalog`) it's threaded through to
* `matchIngredientName`/`matchUnit` for stemming, and picks the right
* spelling of the "piece" fallback below.
*/
export function translateRecipeIngredients(
ingredients: ParsedRecipeIngredient[],
ingredientCatalog: IngredientMatchEntry[],
unitCatalog: UnitMatchEntry[],
locale = "en",
): TranslatedRecipeIngredient[] {
const fallbackCountUnitLabel = FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE[locale] ?? "piece";
return ingredients.map((ingredient) => {
const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog, locale);
const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog);
const extracted = extractQuantity(ingredient.rawText);
const quantity = ingredient.quantity ?? extracted.quantity;
const unitText = ingredient.unit ?? extracted.remainder;
const unitId =
matchUnit(unitText, unitCatalog, locale) ??
(quantity !== null ? matchUnit(fallbackCountUnitLabel, unitCatalog, locale) : null);
matchUnit(unitText, unitCatalog) ??
(quantity !== null ? matchUnit(FALLBACK_COUNT_UNIT_LABEL, unitCatalog) : null);
return { ...ingredient, quantity, ingredientId, unitId };
});
}
@ -288,19 +273,13 @@ export function mergeDuplicateIngredients(
* locale that doesn't match the actual text's language doesn't degrade
* gracefully, it just gets things wrong.
*
* Ingredient/unit matching has data for `"en"` and `"fr"` today
* (`INGREDIENT_LABELS_EN`/`_FR`, `UNIT_LABELS_EN`/`_FR`, `packages/shared`)
* `loadIngredientCatalog(locale)`/`loadUnitCatalog(locale)` are always
* called, never specially skipped for a particular locale: a locale with no
* label table of its own (anything but `"en"`/`"fr"`) just gets back empty
* catalogs from those two loaders, and `translateRecipeIngredients` over an
* empty catalog naturally leaves every ingredient's `ingredientId`/`unitId`
* at the neutral `null` `translateRecipeSteps` already stubs in the same
* "no matching-language data" degradation tech-step matching already has
* for a locale with no mappings, just arrived at by *not* special-casing
* which locales are "supported" here at all (that's `INGREDIENT_LABELS_BY_LOCALE`/
* `UNIT_LABELS_BY_LOCALE`'s job, in `ingredient-matcher.ts` this function
* doesn't need its own copy of that list to stay in sync with).
* Ingredient/unit matching only has English data today
* (`INGREDIENT_LABELS_EN`/`UNIT_LABELS_EN`, `packages/shared`) for any
* `locale` other than `"en"` this skips `loadIngredientCatalog`/
* `loadUnitCatalog` entirely and leaves every ingredient's `ingredientId`/
* `unitId` at the neutral `null` `translateRecipeSteps` already stubs in,
* the same "no matching-language data" degradation tech-step matching
* already has for a locale with no mappings.
*/
export async function translateRecipe(
recipe: ParsedRecipe,
@ -309,18 +288,15 @@ export async function translateRecipe(
try {
const translated = await translateRecipeSteps(recipe, locale);
if (locale !== "en") return translated;
const [ingredientCatalog, unitCatalog] = await Promise.all([
loadIngredientCatalog(locale),
loadUnitCatalog(locale),
loadIngredientCatalog(),
loadUnitCatalog(),
]);
return {
...translated,
ingredients: translateRecipeIngredients(
recipe.ingredients,
ingredientCatalog,
unitCatalog,
locale,
),
ingredients: translateRecipeIngredients(recipe.ingredients, ingredientCatalog, unitCatalog),
};
} catch (err) {
// Rethrown as-is — the caller (`sources.service.ts`) already

View file

@ -1,263 +0,0 @@
/**
* 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).
*
* Deliberately *not* reusing `TECH_STEP_TRAINING_DATA`'s own `utterances`
* verbatim scoring the classifier against the exact sentences it was
* trained on would measure memorization, not generalization. Every
* description below is original phrasing; where a case still needs to name
* a technique's own verb to be labeled with confidence (most of them, see
* this file's own limits below), it's at least a different sentence shape
* than anything in the training corpus.
*
* `expectedKeys` is a multiset in reading order (see
* `TechStepEvalOutcome`'s doc comment in `tech-step-evaluator.ts` for why
* order isn't scored but repetition is) of `TechStep.key`s resolved to
* real DB ids and back by `tech-step-eval-runner.ts`, this file only ever
* deals in stable keys so it doesn't need DB access to author or read.
*
* Known limit of this dataset, confirmed against a real run (see
* `MIN_OVERALL_F1`'s own doc comment, `tech-step-eval-runner.ts`): most
* cases anchor on a technique's own registered synonym, but `_classifyClause`
* only falls back to that anchor when the intent classifier's own score is
* *below* `CONFIDENCE_THRESHOLD` a confidently *wrong* whole-clause
* classification (e.g. "Blanchissez les haricots verts..." scoring
* confidently as `peel` despite the correct `blanch` anchor) overrides the
* anchor just as readily as a confidently *right* one does, so this
* dataset genuinely does measure real classifier failures, not just a
* synthetic floor. A handful of such real mismatches are expected and
* intentionally left uncorrected here (see `MIN_OVERALL_F1`'s doc comment)
* fixing the classifier's actual behavior on them is corpus work for a
* future change, not something to hide by loosening this dataset's own
* expectations to match whatever it currently outputs.
*/
export interface TechStepEvalCase {
description: string;
locale: "fr" | "en";
expectedKeys: string[];
}
export const TECH_STEP_EVAL_DATASET: TechStepEvalCase[] = [
// --- One straightforward case per technique (fr), covering all 26 ---
{
description: "Faites cuire les pâtes al dente dans une grande casserole d'eau bien salée.",
locale: "fr",
expectedKeys: ["cook"],
},
{
description: "Faites bouillir l'eau dans une grande casserole avant d'y plonger les pâtes.",
locale: "fr",
expectedKeys: ["boil"],
},
{
description: "Plongez les beignets dans l'huile très chaude pour les faire frire.",
locale: "fr",
expectedKeys: ["fry"],
},
{
description: "Faites fondre le chocolat noir au bain-marie en remuant.",
locale: "fr",
expectedKeys: ["melt"],
},
{
description: "Déglacez la casserole avec un trait de vinaigre balsamique.",
locale: "fr",
expectedKeys: ["deglaze"],
},
{
description: "Laissez frémir la sauce tomate vingt minutes à couvert.",
locale: "fr",
expectedKeys: ["simmer"],
},
{
description: "Faites rôtir la volaille entière sur la broche du four.",
locale: "fr",
expectedKeys: ["roast"],
},
{
description: "Faites griller les brochettes de poulet quelques minutes de chaque côté.",
locale: "fr",
expectedKeys: ["grill"],
},
{
description: "Faites sauter les champignons à feu vif dans une poêle très chaude.",
locale: "fr",
expectedKeys: ["panFry"],
},
{
description: "Blanchissez les haricots verts trois minutes avant de les refroidir.",
locale: "fr",
expectedKeys: ["blanch"],
},
{
description: "Laissez mariner les brochettes de poulet deux heures au frais.",
locale: "fr",
expectedKeys: ["marinate"],
},
{
description: "Hachez grossièrement le persil frais avant de le parsemer.",
locale: "fr",
expectedKeys: ["chop"],
},
{
description: "Épluchez les carottes avant de les couper en rondelles.",
locale: "fr",
expectedKeys: ["peel"],
},
{
description: "Émincez finement l'échalote pour la vinaigrette.",
locale: "fr",
expectedKeys: ["mince"],
},
{
description: "Mélangez la farine, le sucre et les œufs dans un grand saladier.",
locale: "fr",
expectedKeys: ["mix"],
},
{
description: "Fouettez énergiquement la crème jusqu'à ce qu'elle épaississe.",
locale: "fr",
expectedKeys: ["whisk"],
},
{
description: "Incorporez délicatement la farine tamisée à la préparation.",
locale: "fr",
expectedKeys: ["foldIn"],
},
{
description: "Réservez la pâte au réfrigérateur pendant que vous préparez la garniture.",
locale: "fr",
expectedKeys: ["setAside"],
},
{
description: "Assaisonnez le poisson avec du sel, du poivre et un filet de citron.",
locale: "fr",
expectedKeys: ["season"],
},
{
description: "Égouttez soigneusement le riz dans une passoire fine.",
locale: "fr",
expectedKeys: ["drain"],
},
{
description:
"Faites dorer les morceaux de veau sur toutes leurs faces avant de mouiller avec le bouillon.",
locale: "fr",
expectedKeys: ["brown"],
},
{
description: "Laissez reposer la viande dix minutes avant de la trancher.",
locale: "fr",
expectedKeys: ["rest"],
},
{
description: "Préchauffez le four à 200 degrés avant d'y glisser le gratin.",
locale: "fr",
expectedKeys: ["preheat"],
},
{
description: "Enfournez la tarte pendant trente-cinq minutes jusqu'à ce qu'elle soit dorée.",
locale: "fr",
expectedKeys: ["bake"],
},
{
description: "Dressez harmonieusement les légumes autour de la pièce de viande.",
locale: "fr",
expectedKeys: ["plate"],
},
{
description: "Nappez le fond du moule d'une fine couche de caramel.",
locale: "fr",
expectedKeys: ["coat"],
},
// --- English coverage (same technique verbs, distinct sentences) ---
{
description: "Simmer the stock gently for forty minutes, skimming occasionally.",
locale: "en",
expectedKeys: ["simmer"],
},
{
description: "Peel the potatoes and rinse them under cold water.",
locale: "en",
expectedKeys: ["peel"],
},
{
description: "Whisk the eggs with a pinch of salt until frothy.",
locale: "en",
expectedKeys: ["whisk"],
},
{
description: "Season the soup generously with black pepper before serving.",
locale: "en",
expectedKeys: ["season"],
},
{
description: "Make sure the chicken is cooked through before serving.",
locale: "en",
expectedKeys: ["cook"],
},
// --- Multi-technique sentences, in reading order ---
{
description: "Préchauffez le four, puis faites rôtir le poulet pendant une heure.",
locale: "fr",
expectedKeys: ["preheat", "roast"],
},
{
description: "Faites revenir les oignons, puis déglacez la poêle avec du vin blanc.",
locale: "fr",
expectedKeys: ["brown", "deglaze"],
},
{
description:
"Faites cuire les légumes à la vapeur, puis assaisonnez-les avec des herbes fraîches.",
locale: "fr",
expectedKeys: ["cook", "season"],
},
{
description:
"Émincez l'oignon, faites-le suer, puis mouillez avec le bouillon et laissez mijoter.",
locale: "fr",
expectedKeys: ["mince", "simmer"],
},
// --- No technique mentioned at all ---
{
description: "Répartissez les convives autour de la table avant de commencer le repas.",
locale: "fr",
expectedKeys: [],
},
{
description: "Rangez les couverts propres dans le tiroir de la cuisine.",
locale: "fr",
expectedKeys: [],
},
{
description: "Take the dishes and glasses out of the cupboard.",
locale: "en",
expectedKeys: [],
},
// --- 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).
{
description: "This recipe calls for two tablespoons of brown sugar.",
locale: "en",
expectedKeys: [],
},
// `rest`'s EN synonyms are anchored phrases ("let it rest"/"resting
// for"...), not bare "rest" — so a sentence using the word in its
// "remainder" sense must not anchor `rest` at all.
{
description: "There is no time to rest before the guests arrive.",
locale: "en",
expectedKeys: [],
},
];

View file

@ -1,71 +0,0 @@
import { prisma } from "../../db/prisma.js";
import { TECH_STEP_EVAL_DATASET } from "./tech-step-eval-dataset.js";
import {
computeTechStepMetrics,
type TechStepEvalOutcome,
type TechStepEvalResult,
} from "./tech-step-evaluator.js";
import { techStepClassifier } from "./tech-step-matcher.js";
/**
* Regression floor (not a target) both `runTechStepEvalSuite`'s consumers
* gate on exported from here (not defined separately in each consumer)
* so the CI regression gate and `scripts/retrain-tech-steps.ts`'s
* pre-backfill gate can never silently drift to different thresholds.
*
* Calibrated against a real run: the classifier trained on the corpus as
* of this constant's introduction scored **0.815** aggregate F1
* (33 TP / 9 FP / 6 FN) against `TECH_STEP_EVAL_DATASET` `0.8` leaves a
* small margin below that for run-to-run noise while still catching a
* real regression (not a floor picked blind before ever running this
* suite see this feature's plan document for that earlier state). The
* mismatches this run surfaced (e.g. "Blanchissez les haricots verts..."
* misclassified as `peel`, a handful of anchor-less sentences expected to
* match nothing instead scoring confidently as some technique) are real,
* known classifier weaknesses evidence this harness is doing its job,
* not something to quietly paper over by loosening the dataset's own
* expectations. Improving them is corpus work for a future change, gated
* by this same suite.
*/
export const MIN_OVERALL_F1 = 0.8;
/**
* Runs {@link TECH_STEP_EVAL_DATASET} against the real, currently-trained
* `techStepClassifier` and returns the aggregate/per-technique metrics
* (`computeTechStepMetrics`, `tech-step-evaluator.ts`) the one place this
* DB-touching "resolve ids to keys, then score" logic lives, shared by
* `test/recipe-matching/tech-step-eval.test.ts` (this feature's CI
* regression gate) and `scripts/retrain-tech-steps.ts` (the same gate, run
* by a maintainer before applying a corpus change). Kept out of
* `tech-step-evaluator.ts` itself, which is deliberately pure/DB-free (see
* that module's own doc comment) so its scoring logic stays unit-testable
* without a database.
*/
export async function runTechStepEvalSuite(): Promise<TechStepEvalResult> {
const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
const keyById = new Map(techSteps.map((techStep) => [techStep.id, techStep.key]));
const outcomes: TechStepEvalOutcome[] = [];
for (const evalCase of TECH_STEP_EVAL_DATASET) {
const techStepIds = await techStepClassifier.matchTechSteps(
evalCase.description,
evalCase.locale,
);
const actualKeys = techStepIds.map((id) => {
const key = keyById.get(id);
// A `techStepId` the classifier resolved that isn't in the seeded
// catalog would be a bug in the classifier or the seed data, not
// this dataset — fail loudly rather than silently dropping it (see
// `_train`'s own comment in `tech-step-matcher.ts` on the
// equivalent, deliberately silent `undefined` case it has to
// tolerate for a different reason).
if (key === undefined) {
throw new Error(`Unknown TechStep id ${id} returned for "${evalCase.description}"`);
}
return key;
});
outcomes.push({ expectedKeys: evalCase.expectedKeys, actualKeys });
}
return computeTechStepMetrics(outcomes);
}

View file

@ -1,135 +0,0 @@
/**
* Precision/recall/F1 for {@link techStepClassifier}'s output against a
* 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
* 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,
* not get merged on the strength of a few manually-checked examples.
*
* Pure (no DB/model access) so it's unit-testable on its own same
* convention as `tech-step-matcher.ts`'s own pure helpers (`normalizeText`,
* `splitIntoClauses`): this module only ever receives already-resolved
* `TechStep.key` strings, never DB ids or a live classifier instance, so it
* has nothing to mock to test.
*/
/** True/false-positive/negative counts for one technique (or the aggregate across all of them), plus the precision/recall/F1 derived from them. */
export interface TechStepMetrics {
truePositives: number;
falsePositives: number;
falseNegatives: number;
precision: number;
recall: number;
f1: number;
}
/**
* One evaluation case's outcome what {@link TechStepEvalCase.expectedKeys}
* said should be found, against what the classifier actually returned for
* that case (already mapped from `TechStepMatch.techStepId` back to
* `TechStep.key`, see `tech-step-eval.test.ts`).
*
* Both lists are *multisets*, not sets a description that names the same
* technique twice (rare, but not impossible: "faire cuire, puis... remettre
* à cuire") is expected to produce two matches, and comparing as plain sets
* would silently treat a classifier that only found one of them as a
* perfect match.
*/
export interface TechStepEvalOutcome {
expectedKeys: string[];
actualKeys: string[];
}
/** {@link computeTechStepMetrics}'s result — the aggregate across every case, plus a breakdown per technique so a regression hiding behind a healthy overall F1 (one technique's recall collapsing, offset by another's improving) is still visible. */
export interface TechStepEvalResult {
overall: TechStepMetrics;
byKey: Record<string, TechStepMetrics>;
}
interface RawCounts {
tp: number;
fp: number;
fn: number;
}
function emptyCounts(): RawCounts {
return { tp: 0, fp: 0, fn: 0 };
}
/** Counts occurrences of each key in a multiset, e.g. `["cook", "cook", "bake"]` -> `{cook: 2, bake: 1}`. */
function countByKey(keys: string[]): Map<string, number> {
const counts = new Map<string, number>();
for (const key of keys) {
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return counts;
}
/**
* Standard vacuous-truth convention for the `0/0` cases: precision defaults
* to `1` when nothing was predicted for a key (`tp + fp === 0` no false
* accusation to be precise about), recall defaults to `1` when nothing was
* expected (`tp + fn === 0` nothing to have missed). Neither inflates F1
* on its own: a technique the classifier fully misses still has `recall =
* 0` (there *were* expected occurrences, just none matched), which is what
* pulls F1 down to `0` for that case regardless of precision's vacuous `1`.
*/
function toMetrics(counts: RawCounts): TechStepMetrics {
const { tp, fp, fn } = counts;
const precision = tp + fp === 0 ? 1 : tp / (tp + fp);
const recall = tp + fn === 0 ? 1 : tp / (tp + fn);
const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
return { truePositives: tp, falsePositives: fp, falseNegatives: fn, precision, recall, f1 };
}
/**
* Aggregates every {@link TechStepEvalOutcome} into one overall
* precision/recall/F1 plus a per-technique breakdown.
*
* Counted key by key, multiset-style, per outcome: for a given technique,
* `min(expectedCount, actualCount)` true positives, any actual occurrences
* beyond that are false positives, any expected occurrences short of that
* are false negatives generalizes the usual set-based TP/FP/FN definition
* to handle a technique mentioned (or matched) more than once in the same
* step without over- or under-counting it.
*/
export function computeTechStepMetrics(outcomes: TechStepEvalOutcome[]): TechStepEvalResult {
const overallCounts = emptyCounts();
const countsByKey = new Map<string, RawCounts>();
for (const outcome of outcomes) {
const expectedCounts = countByKey(outcome.expectedKeys);
const actualCounts = countByKey(outcome.actualKeys);
const allKeys = new Set([...expectedCounts.keys(), ...actualCounts.keys()]);
for (const key of allKeys) {
const expected = expectedCounts.get(key) ?? 0;
const actual = actualCounts.get(key) ?? 0;
const tp = Math.min(expected, actual);
const fp = Math.max(0, actual - expected);
const fn = Math.max(0, expected - actual);
overallCounts.tp += tp;
overallCounts.fp += fp;
overallCounts.fn += fn;
const keyCounts = countsByKey.get(key) ?? emptyCounts();
keyCounts.tp += tp;
keyCounts.fp += fp;
keyCounts.fn += fn;
countsByKey.set(key, keyCounts);
}
}
const byKey: Record<string, TechStepMetrics> = {};
for (const [key, counts] of countsByKey) {
byKey[key] = toMetrics(counts);
}
return { overall: toMetrics(overallCounts), byKey };
}

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,90 +241,77 @@ 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;
const CONFIDENCE_THRESHOLD = 0.75;
/**
* One clause's full classification detail the finer-grained sibling of
* {@link TechStepMatch}, exposing the raw intent/score
* `TechStepClassifierService`'s private `_classifyClause` normally
* collapses into a single accepted-or-fallback verdict. Nothing on the
* interactive save/read path needs this (that's exactly what
* `_classifyClause`'s threshold + fallback logic is for) it exists for
* `services/tech-step-llm-worker`'s "audit low-confidence clauses" job
* (`modules/internal/tech-step-worker.service.ts`'s `getAuditBatch`), which
* needs to see *which* clauses the classifier itself wasn't sure about, not
* just its final best-effort verdict.
*/
export interface TechStepClauseClassification {
/** The clause's own text (`description.slice(start, end)`, trimmed). */
clauseText: string;
start: number;
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. */
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 +335,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 +368,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,
});
}
@ -464,61 +388,6 @@ export class TechStepClassifierService {
}
}
/**
* Splits `description` into clauses exactly like {@link matchTechStepSpans}
* does, but returns each clause's *raw* classification detail
* ({@link TechStepClauseClassification}) instead of the threshold-applied,
* anchor-fallback-resolved `TechStepMatch` see that type's doc comment
* for why/who needs this. Deliberately a separate traversal rather than a
* shared refactor with `matchTechStepSpans`/`_classifyClause`: this method
* exists purely to add a new, additive read path without risking a
* behavior change to the two already-relied-on methods above.
*/
public async classifyClauses(
description: string,
locale: string,
): Promise<TechStepClauseClassification[]> {
try {
await this._ensureTechStepIdsLoaded();
if (description.trim().length === 0) return [];
const nerResult = await intentServiceClient.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 clauses = splitIntoClauses(description, candidates);
const results: TechStepClauseClassification[] = [];
for (const clause of clauses) {
const clauseText = description.slice(clause.start, clause.end).trim();
const anchorUid = clause.anchor?.uid ?? null;
if (clauseText.length === 0) {
results.push({
clauseText,
start: clause.start,
end: clause.end,
anchorUid,
intentUid: null,
score: 0,
});
continue;
}
const result = await intentServiceClient.process(locale, clauseText);
results.push({
clauseText,
start: clause.start,
end: clause.end,
anchorUid,
intentUid: result.intent,
score: result.intent === null ? 0 : result.score,
});
}
return results;
} catch (err) {
throw err; // see matchTechStepSpans()'s catch comment above
}
}
/**
* Convenience wrapper around {@link matchTechStepSpans} for callers that
* only care about *which* techniques matched, not where e.g.
@ -552,8 +421,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 +432,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
}
}
/** `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;
}
}
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);
}
}
}
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

@ -1,50 +0,0 @@
import { timingSafeEqual } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode } from "@batch-cooking/shared";
import type { NextFunction, Request, Response } from "express";
import { env } from "../config/env.js";
/** Header `services/tech-step-llm-worker` sends its shared secret on. Not `Authorization`/a bearer scheme — this isn't a user session, just one internal caller authenticating to another, same "one flat shared secret" shape as e.g. a webhook signing header. */
const INTERNAL_WORKER_SECRET_HEADER = "x-internal-worker-secret";
/**
* Express middleware guarding `/internal/tech-steps/*` the surface
* `services/tech-step-llm-worker` (a process outside this monorepo, no
* Prisma access of its own, see that service's own README) reads
* low-confidence NLP clauses and pending `StepTechStepCorrection`s from,
* and posts `TechStepTrainingSuggestion`s back to. Never reachable by an
* end user's session cookie deliberately a *different* auth mechanism
* than {@link requireAuth} (`require-auth.ts`), not layered on top of it,
* since the worker has no `UserProfile`/session of its own to authenticate
* as.
*
* Fails closed: an unset `INTERNAL_WORKER_SECRET` (the default in any
* environment that doesn't run the worker, see `config/env.ts`) rejects
* every request rather than leaving the surface open, same posture as a
* misconfigured `JWT_SECRET` would if it had a working fallback.
*
* @throws {HttpError} `401 NOT_AUTHENTICATED` if the header is missing,
* wrong, or the server has no secret configured at all never
* distinguishes the reason, same posture as {@link requireAuth}.
*/
export function requireInternalWorker(req: Request, _res: Response, next: NextFunction): void {
const provided = req.header(INTERNAL_WORKER_SECRET_HEADER);
if (env.INTERNAL_WORKER_SECRET === undefined || provided === undefined) {
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
return;
}
// `timingSafeEqual` throws on mismatched buffer lengths rather than
// returning `false` — checked separately first. A length mismatch alone
// already means "not equal", so this loses no timing-attack protection
// (an attacker learns nothing beyond what a differing length itself
// already reveals, no different from `!==` on the common case where the
// secret's real length isn't a secret worth protecting).
const expected = Buffer.from(env.INTERNAL_WORKER_SECRET);
const actual = Buffer.from(provided);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
return;
}
next();
}

View file

@ -1,49 +0,0 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
auditBatchQuerySchema,
submitTrainingSuggestionsSchema,
workerBatchQuerySchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { requireInternalWorker } from "../../middlewares/require-internal-worker.js";
import {
getAuditBatch,
getPendingCorrections,
submitTrainingSuggestions,
} from "./tech-step-worker.service.js";
/**
* Router mounted at `/internal/tech-steps` in app.ts every route requires
* {@link requireInternalWorker}, never {@link requireAuth}
* (`middlewares/require-auth.ts`): this is `services/tech-step-llm-worker`
* authenticating as itself, not a user session. See that middleware's own
* doc comment for why the two are deliberately separate mechanisms.
*/
export const techStepWorkerRouter = Router();
techStepWorkerRouter.get(
"/audit-batch",
requireInternalWorker,
wrapAsyncHandler(async (req, res) => {
const input = auditBatchQuerySchema.parse(req.query);
res.status(200).json(await getAuditBatch(input.locale, input.limit));
}),
);
techStepWorkerRouter.get(
"/pending-corrections",
requireInternalWorker,
wrapAsyncHandler(async (req, res) => {
const input = workerBatchQuerySchema.parse(req.query);
res.status(200).json(await getPendingCorrections(input.limit));
}),
);
techStepWorkerRouter.post(
"/training-suggestions",
requireInternalWorker,
wrapAsyncHandler(async (req, res) => {
const input = submitTrainingSuggestionsSchema.parse(req.body);
res.status(201).json(await submitTrainingSuggestions(input));
}),
);

View file

@ -1,201 +0,0 @@
import { HttpError } from "@batch-cooking/error-tools";
import {
ErrorCode,
type PendingTechStepCorrectionView,
type SubmitTrainingSuggestionsInput,
type TechStepAuditClauseView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import {
CONFIDENCE_THRESHOLD,
techStepClassifier,
} from "../../lib/recipe-matching/tech-step-matcher.js";
/**
* Read/write surface `services/tech-step-llm-worker` calls through
* `/internal/tech-steps/*` (`tech-step-worker.routes.ts`, guarded by
* `requireInternalWorker`) the worker has no Prisma client or database
* credentials of its own (see that service's own README), so every
* corrections/audit-sample read and every suggestion write goes through
* here rather than the worker touching this schema directly. Keeps
* `apps/api` the single owner of the schema/migrations, and keeps the
* worker a pure "read some text, run inference, post a suggestion" process
* with nothing to keep in sync if the schema changes shape.
*/
/**
* How many of the most recently created `Step`s {@link getAuditBatch} scans
* per call before filtering down to low-confidence clauses a fixed
* recency-biased sample, not every `Step` in the database, to keep this
* endpoint's cost bounded regardless of how large the recipe catalog gets.
* Recently-added steps are also the steps most likely to still use
* vocabulary the training corpus hasn't caught up with yet, which is
* exactly what this audit is for. A smarter sampling strategy (e.g.
* weighted by how often a recipe is actually viewed/planned) is future
* work, not needed for this feature's first version.
*/
const AUDIT_SAMPLE_SIZE = 200;
/**
* Every low-confidence clause found across a recency-biased sample of
* existing `Step`s (see {@link AUDIT_SAMPLE_SIZE}), for
* `services/tech-step-llm-worker`'s `audit-low-confidence` job to get a
* second opinion on. "Low-confidence" mirrors exactly what
* `TechStepClassifierService._classifyClause` itself distrusts (a clause
* with an NER anchor but a classifier score under
* {@link CONFIDENCE_THRESHOLD}) the same clauses that pipeline already
* has to fall back to keyword-anchor guessing for, not an arbitrary
* separate cutoff.
*/
export async function getAuditBatch(
locale: string,
limit: number,
): Promise<TechStepAuditClauseView[]> {
try {
const steps = await prisma.step.findMany({
orderBy: { id: "desc" },
take: AUDIT_SAMPLE_SIZE,
select: { id: true, recipeId: true, description: true },
});
const results: TechStepAuditClauseView[] = [];
for (const step of steps) {
if (results.length >= limit) break;
const clauses = await techStepClassifier.classifyClauses(step.description, locale);
for (const clause of clauses) {
if (results.length >= limit) break;
const isLowConfidence = clause.anchorUid !== null && clause.score < CONFIDENCE_THRESHOLD;
if (!isLowConfidence) continue;
results.push({
stepId: step.id,
recipeId: step.recipeId,
clauseText: clause.clauseText,
anchorKey: clause.anchorUid,
intentKey: clause.intentUid,
score: clause.score,
locale,
});
}
}
return results;
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Every `StepTechStepCorrection` not yet turned into a
* `TechStepTrainingSuggestion` (`consumedAt IS NULL`), oldest first a
* FIFO queue the worker's `transform-corrections` job drains, `limit` at a
* time.
*
* `correctedTechStepId IS NOT NULL` on top of `consumedAt IS NULL`: a
* correction that *removes* a match ("no technique belongs here",
* `correctedTechStepId: null` see `StepTechStepCorrection`'s schema doc
* comment) has no technique to propose new positive training data *for*.
* Surfacing it here would leave it permanently unconsumable (the worker
* has nothing to submit a suggestion for, so it would never stamp
* `consumedAt`, and it would keep re-appearing in every future batch
* forever) excluded at the source instead, not filtered/skipped
* downstream by the worker.
*/
export async function getPendingCorrections(
limit: number,
): Promise<PendingTechStepCorrectionView[]> {
try {
const corrections = await prisma.stepTechStepCorrection.findMany({
where: { consumedAt: null, correctedTechStepId: { not: null } },
orderBy: { createdAt: "asc" },
take: limit,
include: {
step: { select: { id: true, recipeId: true, description: true } },
previousTechStep: { select: { key: true } },
correctedTechStep: { select: { key: true } },
},
});
return corrections.map((correction) => ({
id: correction.id,
stepId: correction.step.id,
recipeId: correction.step.recipeId,
clauseText: correction.step.description.slice(correction.start, correction.end),
start: correction.start,
end: correction.end,
previousTechStepKey: correction.previousTechStep?.key ?? null,
correctedTechStepKey: correction.correctedTechStep?.key ?? null,
}));
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Persists a batch of `TechStepTrainingSuggestion`s and, for every
* suggestion sourced from a correction, stamps that correction's
* `consumedAt` in the same transaction so a worker run that crashes
* partway through never leaves a correction consumed with no matching
* suggestion, or a suggestion created against a correction still (wrongly)
* eligible to be picked up again by the next run.
*
* @throws {HttpError} `404 TECH_STEP_NOT_FOUND` if any `techStepKey` in the
* batch doesn't match a reference `TechStep` rejects the *whole* batch
* rather than skipping the bad entries, on the theory that a worker
* sending an unknown key is more likely a version-skew bug (its own
* taxonomy copy, `services/tech-step-llm-worker/src/tech-step-taxonomy.ts`,
* drifting from this API's `TechStep` catalog) than a one-off it should
* silently tolerate.
*/
export async function submitTrainingSuggestions(
input: SubmitTrainingSuggestionsInput,
): Promise<{ created: number }> {
try {
const techStepKeys = [
...new Set(input.suggestions.map((suggestion) => suggestion.techStepKey)),
];
const techSteps = await prisma.techStep.findMany({
where: { key: { in: techStepKeys } },
select: { id: true, key: true },
});
const techStepIdByKey = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
const missingKeys = techStepKeys.filter((key) => !techStepIdByKey.has(key));
if (missingKeys.length > 0) {
throw new HttpError(
404,
ErrorCode.TECH_STEP_NOT_FOUND,
`Unknown techStepKey(s): ${missingKeys.join(", ")}`,
);
}
await prisma.$transaction(async (tx) => {
for (const suggestion of input.suggestions) {
// Non-null by construction — every key in `input.suggestions` was
// just confirmed present in `techStepIdByKey` above (the `missingKeys`
// check would have thrown otherwise).
const techStepId = techStepIdByKey.get(suggestion.techStepKey);
if (techStepId === undefined) continue;
await tx.techStepTrainingSuggestion.create({
data: {
techStepId,
locale: suggestion.locale,
suggestedSynonyms: suggestion.suggestedSynonyms,
suggestedUtterances: suggestion.suggestedUtterances,
sourceType: suggestion.sourceType,
sourceCorrectionId: suggestion.sourceCorrectionId ?? null,
},
});
if (suggestion.sourceCorrectionId !== null && suggestion.sourceCorrectionId !== undefined) {
await tx.stepTechStepCorrection.update({
where: { id: suggestion.sourceCorrectionId },
data: { consumedAt: new Date() },
});
}
}
});
return { created: input.suggestions.length };
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}

View file

@ -1,506 +0,0 @@
import { HttpError } from "@batch-cooking/error-tools";
import {
ErrorCode,
type StepTechStepCorrectionView,
type SubmitTechStepCorrectionInput,
type SubmitTechStepCorrectionResult,
} from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import { assertRecipeVisible, toStepTechStepViews } from "./recipe.service.js";
/**
* User-submitted corrections to a step's detected techniques
* (`StepTechStepCorrection` in schema.prisma) kept in its own module
* rather than folded into `recipe.service.ts`, same "one file per concern"
* split that file itself follows for `tech-step-matcher.ts`. Deliberately
* open to *any* viewer who can see the recipe, not just its author (unlike
* every write path in `recipe.service.ts`, which uses `assertIsAuthor`)
* correcting a mislabeled technique isn't editing the recipe's own
* content, and restricting it to authors would starve the training-data
* feedback loop (`services/tech-step-llm-worker`) of the volume it needs.
*/
type CorrectionWithTechSteps = Prisma.StepTechStepCorrectionGetPayload<{
include: { previousTechStep: true; correctedTechStep: true };
}>;
const correctionInclude = {
previousTechStep: true,
correctedTechStep: true,
} satisfies Prisma.StepTechStepCorrectionInclude;
/**
* Loads `stepId`'s current `description` length (the only thing a
* correction needs from the step itself), or throws `404 STEP_NOT_FOUND`
* if no such step exists, or if it exists but doesn't belong to `recipeId`
* (the route's own `:id`/`:stepId` nesting is meaningless otherwise a
* request naming a real step under the wrong recipe should look identical
* to naming one that doesn't exist, same "don't leak which part was wrong"
* posture `assertRecipeVisible` already has for visibility). Otherwise
* whatever {@link assertRecipeVisible} throws (`404 RECIPE_NOT_FOUND`,
* never `403`) if the recipe exists but isn't visible to the viewer.
*/
async function loadVisibleStepOrThrow(
recipeId: number,
stepId: number,
viewerId: number,
viewerHouseId: number | null,
): Promise<{ id: number; descriptionLength: number }> {
try {
const step = await prisma.step.findUnique({
where: { id: stepId },
select: { id: true, recipeId: true, description: true },
});
if (!step || step.recipeId !== recipeId) {
throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`);
}
await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId);
return { id: step.id, descriptionLength: step.description.length };
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/** Throws `404 TECH_STEP_NOT_FOUND` if any id in `ids` doesn't match a reference `TechStep` row — same shape as `recipe.service.ts`'s `assertIngredientsExist`/`assertUnitsExist` for the recipe payload's own reference ids. */
async function assertTechStepsExist(ids: number[]): Promise<void> {
try {
if (ids.length === 0) return;
const found = await prisma.techStep.findMany({
where: { id: { in: ids } },
select: { id: true },
});
const foundIds = new Set(found.map((techStep) => techStep.id));
const missing = ids.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new HttpError(
404,
ErrorCode.TECH_STEP_NOT_FOUND,
`TechStep ids not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see loadVisibleStepOrThrow's catch comment
}
}
/** 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
* schema doc comment, sort last) the dense, reading-order 0-based
* sequence `@@id([stepId, order])` requires, regardless of whether a
* caller just inserted, updated, or deleted a row. Simpler and less
* error-prone than shifting only the affected neighbors' `order` by hand.
*
* Two passes, through a disjoint negative range first: updating straight
* into the final 0..N-1 positions in one pass risks a transient
* `(stepId, order)` collision (e.g. the row destined for `order: 0` isn't
* necessarily the one already sitting there) `order` is always `>= 0`
* in real usage, so a negative range can never collide with a live row.
*
* Exported for `scripts/backfill-tech-steps.ts` to reuse after it
* recomputes just the `"auto"` subset of a step's rows, so the combined
* `"auto"` + `"manual"` sequence still ends up in one coherent
* reading-order.
*/
export async function renumberStepTechSteps(
tx: Prisma.TransactionClient,
stepId: number,
): Promise<void> {
const rows = await tx.stepTechStep.findMany({ where: { stepId } });
const sorted = [...rows].sort(
(a, b) => (a.start ?? Number.POSITIVE_INFINITY) - (b.start ?? Number.POSITIVE_INFINITY),
);
for (const [index, row] of sorted.entries()) {
await tx.stepTechStep.update({
where: { stepId_order: { stepId, order: row.order } },
data: { order: -(index + 1) },
});
}
for (const [index] of sorted.entries()) {
await tx.stepTechStep.update({
where: { stepId_order: { stepId, order: -(index + 1) } },
data: { order: index },
});
}
}
/** 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
* `services/tech-step-llm-worker` to eventually process (see
* `StepTechStepCorrection`'s schema doc comment; this is *in addition to*
* that offline feedback loop, not instead of it). `previousTechStepId`/
* `correctedTechStepId` mean exactly what they do on
* `StepTechStepCorrection` itself (`SubmitTechStepCorrectionInput`'s doc
* comment, `packages/shared`):
*
* - `correctedTechStepId` set (add or relabel): a `"manual"` row is
* written at the correction's own `[start, end)` updating the
* existing entry in place when one matching `previousTechStepId`
* overlaps this span, otherwise inserting a new one. No `contextStart`/
* `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).
*
* 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.
*/
async function applyManualCorrection(
tx: Prisma.TransactionClient,
stepId: number,
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 } });
const target =
previousTechStepId !== null
? existing.find(
(row) =>
row.techStepId === previousTechStepId &&
row.start !== null &&
row.end !== null &&
row.start < span.end &&
span.start < row.end,
)
: 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 } },
data: {
techStepId: correctedTechStepId,
start: span.start,
end: span.end,
contextStart: null,
contextEnd: null,
source: "manual",
},
});
} else {
await tx.stepTechStep.create({
data: {
stepId,
techStepId: correctedTechStepId,
order,
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 } } });
}
await renumberStepTechSteps(tx, stepId);
}
function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorrectionView {
return {
id: correction.id,
start: correction.start,
end: correction.end,
previousTechStep: correction.previousTechStep
? { id: correction.previousTechStep.id, key: correction.previousTechStep.key }
: null,
correctedTechStep: correction.correctedTechStep
? { id: correction.correctedTechStep.id, key: correction.correctedTechStep.key }
: null,
createdAt: correction.createdAt.toISOString(),
};
}
/**
* Records one correction to `stepId`'s detected techniques, submitted by
* `correctorId`, and immediately applies its effect to the step's real
* `StepTechStep` sequence (a `"manual"`-tagged row see
* {@link applyManualCorrection}) see
* {@link SubmitTechStepCorrectionInput}'s doc comment (`packages/shared`)
* for what `previousTechStepId`/`correctedTechStepId` each mean. The audit
* record itself is never edited/deleted afterward (see
* `StepTechStepCorrection`'s schema doc comment) only the live sequence
* changes on a later correction to the same span.
*
* @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.
*/
export async function submitTechStepCorrection(
recipeId: number,
stepId: number,
input: SubmitTechStepCorrectionInput,
correctorId: number,
viewerHouseId: number | null,
): Promise<SubmitTechStepCorrectionResult> {
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) {
throw new HttpError(
400,
ErrorCode.INVALID_CORRECTION_SPAN,
`Span [${span.start}, ${span.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({
data: {
stepId: step.id,
correctorId,
start: input.start,
end: input.end,
previousTechStepId: input.previousTechStepId ?? null,
correctedTechStepId: input.correctedTechStepId ?? null,
},
include: correctionInclude,
});
await applyManualCorrection(
tx,
step.id,
{ 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 } },
},
});
return { correction: createdCorrection, techSteps: freshTechSteps };
});
return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
} catch (err) {
throw err; // see loadVisibleStepOrThrow's catch comment
}
}
/**
* Every correction submitted so far for `stepId`, most recent first
* mainly useful for a user checking what's already been submitted (by
* anyone) for a span before adding another (see `StepTechStepCorrectionView`'s
* doc comment, `packages/shared`).
*
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` see {@link loadVisibleStepOrThrow}.
*/
export async function listTechStepCorrections(
recipeId: number,
stepId: number,
viewerId: number,
viewerHouseId: number | null,
): Promise<StepTechStepCorrectionView[]> {
try {
const step = await loadVisibleStepOrThrow(recipeId, stepId, viewerId, viewerHouseId);
const corrections = await prisma.stepTechStepCorrection.findMany({
where: { stepId: step.id },
orderBy: { createdAt: "desc" },
include: correctionInclude,
});
return corrections.map(toCorrectionView);
} catch (err) {
throw err; // see loadVisibleStepOrThrow's catch comment
}
}

View file

@ -4,7 +4,6 @@ import {
createRecipeSchema,
ErrorCode,
listRecipesSchema,
submitTechStepCorrectionSchema,
updateRecipeSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
@ -18,10 +17,6 @@ import {
removeFavorite,
updateRecipe,
} from "./recipe.service.js";
import {
listTechStepCorrections,
submitTechStepCorrection,
} from "./recipe-tech-step-correction.service.js";
/** Router mounted at `/recipes` in app.ts. Every route requires a session — the catalog is shared across households, not public (same reasoning as `planning`/`house`: it's app content, not signup-time reference data). */
export const recipeRouter = Router();
@ -35,15 +30,6 @@ function parseRecipeId(rawId: string | undefined): number {
return id;
}
/** Same shape as {@link parseRecipeId}, for the `:stepId` route param of the tech-step-correction routes below — a distinct function only so the error message names the right param. */
function parseStepId(rawId: string | undefined): number {
const id = Number(rawId);
if (!Number.isInteger(id)) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "stepId must be an integer");
}
return id;
}
recipeRouter.get(
"/",
requireAuth,
@ -123,29 +109,3 @@ recipeRouter.delete(
res.status(204).end();
}),
);
// Open to any authenticated viewer who can see the recipe, not just its
// author — see recipe-tech-step-correction.service.ts's own doc comment
// for why.
recipeRouter.post(
"/:id/steps/:stepId/corrections",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parseRecipeId(req.params.id);
const stepId = parseStepId(req.params.stepId);
const input = submitTechStepCorrectionSchema.parse(req.body);
const { id: correctorId, houseId } = res.locals.userProfile;
res.status(201).json(await submitTechStepCorrection(id, stepId, input, correctorId, houseId));
}),
);
recipeRouter.get(
"/:id/steps/:stepId/corrections",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parseRecipeId(req.params.id);
const stepId = parseStepId(req.params.stepId);
const { id: viewerId, houseId } = res.locals.userProfile;
res.status(200).json(await listTechStepCorrections(id, stepId, viewerId, houseId));
}),
);

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,
@ -156,48 +132,19 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
* existed) still has a perfectly good match to show, just without the
* wider highlight, so those two are included only when both are present
* rather than dropping the whole entry over a still-missing "nice to have".
*
* Exported also called by `recipe-tech-step-correction.service.ts` to
* shape the fresh `StepTechStep` sequence it returns right after applying
* a manual correction, so both places convert the exact same way rather
* than risking two slightly different views of the same rows.
*/
export function toStepTechStepViews(
function toStepTechStepViews(
techSteps: RecipeWithDetails["steps"][number]["techSteps"],
): 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 } = stepTechStep;
if (start === null || end === null) continue;
views.push({
techStep: { id: techStep.id, key: techStep.key },
start,
end,
// `source` is a plain DB `String`, not a Prisma enum (see
// `StepTechStep`'s schema doc comment) — narrowed here rather than
// trusting the column's own type, so a value this app never wrote
// (a manual DB edit, a future migration gone wrong) degrades to the
// safer "auto" reading instead of surfacing an invalid
// `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 +539,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

@ -11,8 +11,10 @@ import {
import { prisma } from "../../db/prisma.js";
import { findImportedRecipeIds } from "../../db/recipe-source-sync.js";
import {
type IngredientMatchEntry,
loadIngredientCatalog,
loadUnitCatalog,
type UnitMatchEntry,
} from "../../lib/recipe-matching/ingredient-matcher.js";
import {
mergeDuplicateIngredients,
@ -27,7 +29,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
@ -139,13 +141,10 @@ export async function browseSource(
* techniques with their exact matched span (`matchTechStepSpans`, the same
* function `recipe.service.ts` uses at real save time see its doc
* comment), all against `adapter.locale`'s catalogs. Ingredient/unit
* matching has data for `"en"`/`"fr"` today (see `ingredient-matcher.ts`);
* `loadIngredientCatalog`/`loadUnitCatalog` are always called with
* `adapter.locale` directly, never specially skipped for a particular
* one a source whose locale has no label table of its own just gets back
* empty catalogs from those two loaders, so every line's `ingredient`/
* `unit` end up `null` the same way, the same graceful "no
* matching-language data" degradation `translateRecipe` already has.
* matching itself only has English data today (see `ingredient-matcher.ts`);
* a non-English-locale source simply gets `ingredient`/`unit: null` on
* every line, the same graceful "no matching-language data" degradation
* `translateRecipe` already has.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
@ -178,8 +177,10 @@ export async function previewSourceItem(
matches: await techStepClassifier.matchTechStepSpans(step.description, adapter.locale),
})),
),
loadIngredientCatalog(adapter.locale),
loadUnitCatalog(adapter.locale),
adapter.locale === "en"
? loadIngredientCatalog()
: Promise.resolve<IngredientMatchEntry[]>([]),
adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve<UnitMatchEntry[]>([]),
prisma.techStep.findMany({ select: { id: true, key: true } }),
]);
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
@ -188,16 +189,10 @@ export async function previewSourceItem(
parsed.ingredients,
ingredientCatalog,
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
@ -230,36 +225,6 @@ export async function previewSourceItem(
end: match.end,
contextStart: match.contextStart,
contextEnd: match.contextEnd,
// A draft preview has no persisted `StepTechStep` row to
// read a real `source` from at all (it isn't a saved
// recipe yet — see `DraftRecipeStepView`'s own doc
// 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

@ -1,123 +1,60 @@
import { pathToFileURL } from "node:url";
import { prisma } from "../db/prisma.js";
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
import { renumberStepTechSteps } from "../modules/recipe/recipe-tech-step-correction.service.js";
/**
* 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
* One-off maintenance script: recomputes every existing `Step`'s
* `StepTechStep` sequence against the *current* classifier
* (`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
* guessing).
* no persisted per-recipe locale to recover for a step that already exists,
* so this matches real resave behavior exactly rather than guessing).
*
* Needed because tech-step detection only ever runs at create/update time
* (`recipe.service.ts`'s `matchStepsTechSteps`), never retroactively a
* step saved before a classifier/corpus change (new vocabulary, or the
* `contextStart`/`contextEnd` columns a previous session added) keeps
* whatever it was matched with at the time until it's next resaved.
* `contextStart`/`contextEnd` columns this same session added) keeps
* whatever it was matched with at the time until it's next resaved. Run
* this after a corpus change to bring every existing step in sync without
* asking users to open and resave every recipe by hand:
*
* `"manual"`-sourced entries (a viewer's correction, applied immediately
* see `recipe-tech-step-correction.service.ts`'s `applyManualCorrection`)
* are never touched by this: only rows with `source: "auto"` are deleted
* and recreated, and any fresh classifier match overlapping an existing
* `"manual"` entry's span is dropped rather than inserted a manual
* correction is meant to *override* the classifier at that exact spot,
* and recomputing must never silently reintroduce (or duplicate-highlight)
* what a user already corrected. `renumberStepTechSteps`
* (`recipe-tech-step-correction.service.ts`) folds the surviving `"auto"` +
* untouched `"manual"` rows back into one coherent reading-order sequence
* afterward.
* pnpm --filter api exec tsx src/scripts/backfill-tech-steps.ts
*
* Exported (not just called from this file's own CLI guard below) so
* `retrain-tech-steps.ts` can run it as one step of its own larger
* maintainer workflow, without shelling out to a second process.
*
* Safe to re-run: with no manual entries and no corpus change since the
* last run, this is a no-op (the same `"auto"` matches get deleted and
* recreated identically); with manual entries present, they're preserved
* on every run by construction.
* Safe to re-run: each step's technique sequence is fully replaced (delete
* + recreate) from the classifier's current output, same as a real edit
* running it twice in a row with no corpus change in between is a no-op.
*/
export async function backfillTechSteps(): Promise<{ total: number; changed: number }> {
async function backfillTechSteps(): Promise<void> {
const steps = await prisma.step.findMany({ select: { id: true, description: true } });
console.info(`Recomputing tech steps for ${steps.length} step(s)...`);
let changed = 0;
for (const step of steps) {
const matches = await techStepClassifier.matchTechStepSpans(step.description, "fr");
await prisma.$transaction(async (tx) => {
const manualRows = await tx.stepTechStep.findMany({
where: { stepId: step.id, source: "manual" },
});
const nonOverlappingMatches = matches.filter(
(match) =>
!manualRows.some(
(manual) =>
manual.start !== null &&
manual.end !== null &&
manual.start < match.end &&
match.start < manual.end,
),
);
await tx.stepTechStep.deleteMany({ where: { stepId: step.id, source: "auto" } });
if (nonOverlappingMatches.length > 0) {
// Placeholder orders, disjoint from the untouched manual rows'
// existing ones (`renumberStepTechSteps` below folds everything
// into a clean 0..N-1 sequence right after — these just need to
// not collide with `@@id([stepId, order])` for this insert).
const startOrder = manualRows.reduce((max, row) => Math.max(max, row.order), -1) + 1;
await tx.stepTechStep.createMany({
data: nonOverlappingMatches.map((match, index) => ({
stepId: step.id,
techStepId: match.techStepId,
order: startOrder + index,
start: match.start,
end: match.end,
contextStart: match.contextStart,
contextEnd: match.contextEnd,
source: "auto",
})),
});
}
await renumberStepTechSteps(tx, step.id);
});
await prisma.$transaction([
prisma.stepTechStep.deleteMany({ where: { stepId: step.id } }),
prisma.stepTechStep.createMany({
data: matches.map((match, order) => ({
stepId: step.id,
techStepId: match.techStepId,
order,
start: match.start,
end: match.end,
contextStart: match.contextStart,
contextEnd: match.contextEnd,
})),
}),
]);
changed += 1;
}
console.info(`Done — ${changed} step(s) recomputed.`);
return { total: steps.length, changed };
}
// Only runs when this file is executed directly (`tsx
// src/scripts/backfill-tech-steps.ts`), not when `retrain-tech-steps.ts`
// imports `backfillTechSteps` above — the standard ESM "is this the entry
// module" check, first needed in this codebase by that new script; every
// prior script here (`seed-runtime.ts`) was always only ever run directly,
// never imported. `pathToFileURL` (not a naive `` `file://${process.argv[1]}` ``
// concatenation) is required for this to actually work on Windows — a
// native Windows path (backslashes, no leading slash before the drive
// letter) doesn't survive being pasted directly after `file://`, so the
// comparison against `import.meta.url` (already a real, correctly-escaped
// `file:///D:/...` URL) always came out false: this guard silently never
// matched, so running this script directly (`tsx
// src/scripts/backfill-tech-steps.ts`) did *nothing* — no error, no
// output, `backfillTechSteps()` simply never called — found only by
// running it for real and noticing zero output where several log lines
// were expected.
const isMainModule = import.meta.url === pathToFileURL(process.argv[1] ?? "").href;
if (isMainModule) {
backfillTechSteps()
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err);
await prisma.$disconnect();
process.exit(1);
});
}
backfillTechSteps()
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err);
await prisma.$disconnect();
process.exit(1);
});

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

@ -1,72 +0,0 @@
import { prisma } from "../db/prisma.js";
/**
* Maintainer-facing report of every `TechStepTrainingSuggestion` still
* `status: "pending"` (`TechStepTrainingSuggestion`'s own schema doc
* 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:
*
* 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).
*/
async function listPendingTrainingSuggestions(): Promise<void> {
const suggestions = await prisma.techStepTrainingSuggestion.findMany({
where: { status: "pending" },
orderBy: [{ techStepId: "asc" }, { createdAt: "asc" }],
include: { techStep: { select: { key: true } } },
});
if (suggestions.length === 0) {
console.info("No pending training suggestions.");
return;
}
const byTechStepKey = new Map<string, typeof suggestions>();
for (const suggestion of suggestions) {
const key = suggestion.techStep.key;
const group = byTechStepKey.get(key);
if (group) {
group.push(suggestion);
} else {
byTechStepKey.set(key, [suggestion]);
}
}
const lines: string[] = [`# Pending tech-step training suggestions (${suggestions.length})`, ""];
for (const [techStepKey, group] of byTechStepKey) {
lines.push(`## ${techStepKey}`, "");
for (const suggestion of group) {
const source =
suggestion.sourceCorrectionId !== null
? `${suggestion.sourceType} (correction #${suggestion.sourceCorrectionId})`
: suggestion.sourceType;
lines.push(`- id ${suggestion.id} · locale ${suggestion.locale} · source: ${source}`);
if (suggestion.suggestedSynonyms.length > 0) {
lines.push(` - synonyms: ${suggestion.suggestedSynonyms.join(", ")}`);
}
if (suggestion.suggestedUtterances.length > 0) {
lines.push(
` - utterances: ${suggestion.suggestedUtterances.map((u) => `"${u}"`).join(", ")}`,
);
}
}
lines.push("");
}
console.info(lines.join("\n"));
}
listPendingTrainingSuggestions()
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err);
await prisma.$disconnect();
process.exit(1);
});

View file

@ -1,103 +0,0 @@
import { prisma } from "../db/prisma.js";
import {
MIN_OVERALL_F1,
runTechStepEvalSuite,
} from "../lib/recipe-matching/tech-step-eval-runner.js";
import { backfillTechSteps } from "./backfill-tech-steps.js";
/** Parses `--applied=1,2,3`/`--rejected=4,5` from argv into id arrays — both optional, both empty by default (a run with neither flag only re-gates + backfills, doesn't touch any suggestion's status). */
function parseSuggestionIds(flag: "applied" | "rejected"): number[] {
const prefix = `--${flag}=`;
const arg = process.argv.find((value) => value.startsWith(prefix));
if (arg === undefined) return [];
return arg
.slice(prefix.length)
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0)
.map((value) => {
const id = Number(value);
if (!Number.isInteger(id)) {
throw new Error(`--${flag}: "${value}" is not a valid integer id`);
}
return id;
});
}
/**
* 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),
* 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.
* 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
* the floor, so a bad edit never reaches every existing recipe.
* 3. Backfills every `Step`'s `StepTechStep` sequence against the new
* corpus ({@link backfillTechSteps}).
* 4. Marks the given suggestion ids `applied`/`rejected`, so
* `list-pending-training-suggestions.ts`'s next report doesn't
* surface them again.
*
* Usage:
*
* pnpm --filter api exec tsx src/scripts/retrain-tech-steps.ts --applied=12,13 --rejected=14
*
* `--applied`/`--rejected` are both optional omitting both still runs
* the gate + backfill, just leaves every suggestion's `status` untouched
* (useful for re-running the backfill alone after a corpus edit made with
* no suggestions involved at all).
*/
async function retrainTechSteps(): Promise<void> {
const appliedIds = parseSuggestionIds("applied");
const rejectedIds = parseSuggestionIds("rejected");
console.info("Evaluating the current classifier against the labeled evaluation set...");
const { overall } = await runTechStepEvalSuite();
console.info(
`F1 ${overall.f1.toFixed(3)} (precision ${overall.precision.toFixed(3)}, recall ${overall.recall.toFixed(3)})`,
);
if (overall.f1 < MIN_OVERALL_F1) {
throw new Error(
`Aggregate F1 ${overall.f1.toFixed(3)} is below the ${MIN_OVERALL_F1} regression floor — refusing to backfill. Revert or fix the corpus change and re-run.`,
);
}
const { total, changed } = await backfillTechSteps();
console.info(`Backfilled ${changed}/${total} step(s).`);
if (appliedIds.length > 0) {
// `updateMany`'s own `count` (rows actually matched/updated), not
// `appliedIds.length` (what was merely *asked for*) — an id that
// doesn't exist (typo, already-processed id) would otherwise log a
// success count that silently doesn't match what really changed.
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
where: { id: { in: appliedIds } },
data: { status: "applied" },
});
console.info(`Marked ${count}/${appliedIds.length} suggestion(s) as applied.`);
}
if (rejectedIds.length > 0) {
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
where: { id: { in: rejectedIds } },
data: { status: "rejected" },
});
console.info(`Marked ${count}/${rejectedIds.length} suggestion(s) as rejected.`);
}
}
retrainTechSteps()
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err);
await prisma.$disconnect();
process.exit(1);
});

View file

@ -9,45 +9,22 @@ 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++) {
try {
await techStepClassifier.warmUp();
return;
} catch (err) {
if (attempt === maxAttempts) {
logger.error("Tech-step classifier warm-up failed after retries", {
error: err instanceof Error ? err.message : String(err),
attempts: attempt,
});
return;
}
const delayMs = 1000 * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
// 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();
} catch (err) {
// 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),
});
}
await warmUpTechStepClassifier();
const server = createServer();
server.listen(env.PORT, () => {

View file

@ -1,425 +0,0 @@
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListItem,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
import { jsonLdRecipeAdapter } from "./json-ld-recipe.js";
const SOURCE_KEY = "750g";
// 750g.com's own site search is a client-side widget (results are fetched
// by the page's own JS after load, nothing server-rendered to scrape) — but
// that JS itself calls this plain GET endpoint, an "AI answer engine" that
// returns an HTML fragment of recipe cards for a free-text query. Verified
// live: works with a bare `fetch`, no special headers/cookies/session
// needed, same as every other adapter in this family. Only used for a
// non-empty query — see `LATEST_RECIPES_URL` for why: this endpoint answers
// a blank query with nothing at all.
const SEARCH_URL = "https://www.750g.com/genius/query/";
// What `list()` reads instead of `SEARCH_URL` for an empty/omitted `query`
// ("browse everything", per `RecipeSourceListParams.query`'s own doc
// comment) — verified live, `SEARCH_URL` responds to a blank query with a
// zero-length body, so browsing this source with no filter typed would
// otherwise always come back empty. `dernieres-recettes.htm` is 750g.com's
// own "latest recipes" archive: real, server-rendered pagination via
// `&page=N` (unlike `SEARCH_URL`, which doesn't paginate at all — see
// `list()`'s own comment on `nextCursor`), same `card-recipe`/`card-link`
// markup `extractRecipeCards` already reads elsewhere on the site. Checked
// live up to `page=500` — genuinely different recipes every time, no
// redirect/clamp once past whatever the real end is (unlike marmiton.ts's
// search, which 404s past its last page), so `list()` treats a page with no
// cards at all as the end-of-results signal instead.
const LATEST_RECIPES_URL = "https://www.750g.com/dernieres-recettes.htm";
/**
* Matches every `<script type="application/ld+json">…</script>` block
* same shape as `JSON_LD_SCRIPT_PATTERN` in json-ld-recipe.ts, kept as its
* own private copy here rather than sharing that module's export: this one
* does textual surgery on the *raw HTML* before `jsonLdRecipeAdapter` ever
* sees it (see {@link sanitizeJsonLdBlocks} below), a different concern
* from extracting-and-parsing blocks into objects.
*/
const JSON_LD_SCRIPT_PATTERN =
/(<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>)([\s\S]*?)(<\/script>)/gi;
/**
* Escapes any raw (unescaped) JSON control character U+0000U+001F
* found *inside* a string literal of `json`, leaving everything outside
* string literals (structural whitespace, brackets, ) untouched. Fixes a
* real bug in 750g.com's own JSON-LD generator: some `HowToStep.text`
* values contain a literal, un-escaped `\r\n` where valid JSON requires
* `\\r\\n` (verified live, e.g.
* https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm — and
* roughly a third of a random sample of recipe pages hit this)
* `JSON.parse` throws "Bad control character in string literal" on these
* pages as-is, which would make `jsonLdRecipeAdapter.parse` wrongly report
* "no JSON-LD Recipe found" on a page that has a perfectly good one.
*
* A blind find/replace across the whole block would be wrong: JSON also
* uses real newlines as *structural* whitespace between tokens
* (pretty-printing), where they're perfectly legal and must be left alone
* only walking the text with string-literal awareness (tracking `"…"`
* boundaries and `\`-escapes) can tell the two apart.
*/
function escapeRawControlCharactersInStrings(json: string): string {
const SHORT_ESCAPES: Record<string, string> = {
"\b": "\\b",
"\f": "\\f",
"\n": "\\n",
"\r": "\\r",
"\t": "\\t",
};
let result = "";
let inString = false;
let escapedNext = false;
for (const ch of json) {
if (!inString) {
if (ch === '"') inString = true;
result += ch;
continue;
}
if (escapedNext) {
result += ch;
escapedNext = false;
continue;
}
if (ch === "\\") {
result += ch;
escapedNext = true;
continue;
}
if (ch === '"') {
inString = false;
result += ch;
continue;
}
if (ch < " ") {
result += SHORT_ESCAPES[ch] ?? `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`;
continue;
}
result += ch;
}
return result;
}
/**
* Runs {@link escapeRawControlCharactersInStrings} over every JSON-LD
* `<script>` block's content in `html`, leaving the rest of the page
* untouched the repair step `fetchDetail`'s raw HTML needs before
* `jsonLdRecipeAdapter.parse` (which does its own extraction/`JSON.parse`
* internally) ever sees it.
*/
function sanitizeJsonLdBlocks(html: string): string {
return html.replace(
JSON_LD_SCRIPT_PATTERN,
(_match, openTag: string, json: string, closeTag: string) =>
`${openTag}${escapeRawControlCharactersInStrings(json)}${closeTag}`,
);
}
/**
* Numeric entities plus a hand-picked table of named ones not a general
* HTML5 entity decoder (400+ named entities exist in the spec), just what's
* actually been observed necessary to clean up 750g.com's French recipe
* text: the five basic XML entities, Latin-1 accented letters, and a
* handful of common punctuation entities.
*/
const NAMED_ENTITIES: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
eacute: "é",
egrave: "è",
ecirc: "ê",
euml: "ë",
agrave: "à",
acirc: "â",
auml: "ä",
icirc: "î",
iuml: "ï",
ocirc: "ô",
ouml: "ö",
ucirc: "û",
ugrave: "ù",
uuml: "ü",
ccedil: "ç",
oelig: "œ",
aelig: "æ",
laquo: "«",
raquo: "»",
lsquo: "",
rsquo: "",
ldquo: "“",
rdquo: "”",
hellip: "…",
ndash: "",
mdash: "—",
deg: "°",
};
/** One pass of numeric (`&#39;`/`&#x27;`) and {@link NAMED_ENTITIES} decoding — see {@link decodeHtmlEntities}, which is what actually runs against parsed text; this is split out only so that function can run it twice. */
function decodeHtmlEntitiesOnce(text: string): string {
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (entity, body: string) => {
if (body[0] === "#") {
const isHex = body[1] === "x" || body[1] === "X";
const codePoint = isHex
? Number.parseInt(body.slice(2), 16)
: Number.parseInt(body.slice(1), 10);
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
}
return NAMED_ENTITIES[body] ?? entity;
});
}
/**
* Decodes HTML entities in free-text pulled from 750g.com, run **twice**:
* its JSON-LD sometimes double-escapes text that already went through its
* own HTML-entity encoder once e.g. a real "é" ends up as `&amp;eacute;`
* (the `&` of an already-produced `&eacute;` got re-escaped to `&amp;`)
* rather than a plain `&eacute;` or a raw "é" (verified live, e.g.
* "Pr&amp;eacute;parez" on
* https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm). One
* pass turns that into `&eacute;` a *newly formed*, valid-looking entity
* so a second pass is needed to resolve it the rest of the way to "é". A
* string with no entities at all (the common case) is unaffected by either
* pass.
*/
function decodeHtmlEntities(text: string): string {
return decodeHtmlEntitiesOnce(decodeHtmlEntitiesOnce(text));
}
/**
* Runs {@link decodeHtmlEntities} over every free-text field of a
* `ParsedRecipe` produced by `jsonLdRecipeAdapter.parse`. `picture`/
* `sourceUrl` are deliberately left untouched they're URLs, not prose,
* and the entity-encoding bug this fixes has only ever been observed in
* name/description/instruction/ingredient text, never in a URL field.
*/
function decodeParsedRecipeText(recipe: ParsedRecipe): ParsedRecipe {
return {
...recipe,
name: decodeHtmlEntities(recipe.name),
description: recipe.description === null ? null : decodeHtmlEntities(recipe.description),
ingredients: recipe.ingredients.map((ingredient) => ({
...ingredient,
rawText: decodeHtmlEntities(ingredient.rawText),
name: decodeHtmlEntities(ingredient.name),
})),
steps: recipe.steps.map((step) => ({
...step,
description: decodeHtmlEntities(step.description),
})),
};
}
/** One recipe card as scraped off a 750g.com results fragment (search or listing page) — see {@link extractRecipeCards}. */
interface SevenFiftyGCard {
url: string;
title: string;
image: string | null;
}
/**
* Scrapes every recipe card out of a 750g.com results HTML fragment
* there's no JSON-LD `ItemList` on this endpoint to lean on (unlike
* marmiton.ts's search page), just the same server-rendered `card-recipe`
* markup 750g.com uses everywhere. Each card's title/url comes from its
* `<a class="card-link">`; its image is whichever `<img>` most recently
* preceded that link, rather than a naive same-index zip of "every image on
* the page" against "every link on the page" a plain fragment like this
* one carries a few extra decorative images with no card of their own
* (verified live: 28 `<img>` tags against 23 real cards for one sample
* query), which would silently shift every image after the first stray one
* onto the wrong title. Each card's own `<img>` always sits immediately
* before its title link in the markup, so "nearest preceding image" is
* unambiguous and doesn't depend on the two counts matching.
*/
function extractRecipeCards(html: string): SevenFiftyGCard[] {
const linkPattern =
/<a\s+href="(https:\/\/www\.750g\.com\/[^"]+)"\s+class="card-link[^"]*">([^<]+)<\/a>/g;
const imagePattern = /<img[^>]*\ssrc="(https:\/\/static\.750g\.com\/images\/[^"]+)"[^>]*>/g;
const images = [...html.matchAll(imagePattern)];
const cards: SevenFiftyGCard[] = [];
let searchFrom = 0;
for (const linkMatch of html.matchAll(linkPattern)) {
let image: string | null = null;
for (const imgMatch of images) {
if (imgMatch.index === undefined || imgMatch.index >= linkMatch.index) break;
if (imgMatch.index >= searchFrom) image = imgMatch[1] ?? null;
}
cards.push({
url: linkMatch[1] ?? "",
title: decodeHtmlEntities(linkMatch[2] ?? ""),
image,
});
searchFrom = linkMatch.index;
}
return cards;
}
/**
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having
* come from this adapter instead (`sourceKey` `"750g"`) same reasoning as
* marmiton.ts's identically-named helper: `fetchDetail`/`parse` below are
* thin wrappers around the generic adapter's own methods, but a caller
* catching `RecipeSourceError` and reading `.sourceKey` should see "750g",
* the source it actually asked about.
*/
function rekeySourceError(err: unknown): unknown {
if (err instanceof RecipeSourceFetchError) {
return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause });
}
if (err instanceof RecipeSourceParseError) {
return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause });
}
return err;
}
/**
* 750g.com one of France's largest recipe sites. Unofficial (`official:
* false`): no published API, this adapter fetches ordinary pages and reads
* the schema.org structured data 750g.com embeds for search engines, built
* on {@link jsonLdRecipeAdapter} the same way marmiton.ts is. Two real
* 750g-specific problems separate this adapter from a pure thin wrapper
* like marmiton.ts, though:
*
* - `list()` has no `ItemList` JSON-LD to read off its search results (see
* {@link extractRecipeCards}) its site search is a client-side widget,
* so a non-empty query instead calls the plain GET endpoint that widget's
* own JS calls internally (`SEARCH_URL`), an "AI answer engine" that
* returns a curated batch of cards rather than an exhaustive, paginated
* catalog verified live, requesting `page=2` of the same query always
* comes back empty, so `nextCursor` is always `null` in that case, same
* as `theMealDbAdapter`'s "one response holds every match". An empty
* query reads `LATEST_RECIPES_URL` instead, a real paginated catalog
* `SEARCH_URL` itself answers a blank query with nothing at all, which
* would otherwise make browsing this source with no filter typed always
* come back empty.
* - `parse()` doesn't delegate to `jsonLdRecipeAdapter.parse` as directly as
* marmiton.ts's does — 750g.com's own JSON-LD generator has two real bugs
* this adapter works around: some pages embed literal, unescaped control
* characters inside a JSON string (see {@link sanitizeJsonLdBlocks}), and
* its free text is sometimes double HTML-entity-encoded (see
* {@link decodeParsedRecipeText}). Both are pre/post-processing around the
* same underlying delegation, not a reimplementation of it.
*/
export const sevenFiftyGAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
key: SOURCE_KEY,
name: "750g",
official: false,
// Un chemin stable, sans le paramètre `?v=…` de cache-busting que 750g.com
// ajoute à ses balises <link> (susceptible de changer à chaque
// déploiement) — cette adresse répond correctement sans lui.
iconUrl: "https://www.750g.com/img/750g/favicons/favicon.svg",
// Le contenu de 750g.com (noms, ingrédients, instructions) est en
// français — détermine contre quel modèle/locale d'étiquettes
// d'ingrédients translateRecipe (recipe-translation.ts) résout les
// recettes de cette source lors d'une prévisualisation/d'un import.
locale: "fr",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
const query = params.query ?? "";
const page = params.cursor ? Number(params.cursor) : 1;
const hasQuery = query.length > 0;
// Deux endpoints distincts selon qu'il y a un texte de recherche ou
// non — voir les commentaires de `SEARCH_URL`/`LATEST_RECIPES_URL` :
// le premier ne répond rien du tout à une requête vide, le second est
// le vrai catalogue paginé "dernières recettes" de 750g.com. `page`
// n'a de sens que pour le second (le premier ne pagine pas — voir
// plus bas) mais est toujours passé, y compris `page=1`, par
// cohérence avec le reste de cette famille d'adaptateurs.
const listUrl = hasQuery
? `${SEARCH_URL}?query=${encodeURIComponent(query)}&query_type=written_query&page=1`
: `${LATEST_RECIPES_URL}?page=${page}`;
let response: Response;
try {
response = await fetch(listUrl);
} catch (cause) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Network error listing 750g recipes (${listUrl})`,
{
cause,
},
);
}
if (!response.ok) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`750g responded ${response.status} (${listUrl})`,
);
}
const html = await response.text();
// No filter for a missing title/url here (unlike marmiton.ts/
// the-meal-db.ts, which drop entries with a null field from a
// structured API response) — `extractRecipeCards`' own regex requires
// at least one character for both, so there's no "absent field" shape
// to guard against.
const items: RecipeSourceListItem[] = extractRecipeCards(html).map((card) => ({
externalId: card.url,
title: card.title,
picture: card.image,
url: card.url,
}));
// La recherche par texte libre ne pagine pas du tout (voir le
// commentaire de `SEARCH_URL`) — `nextCursor` y vaut toujours `null`,
// même logique que `theMealDbAdapter`. "Dernières recettes" pagine
// réellement (voir le commentaire de `LATEST_RECIPES_URL`) — une page
// sans aucune carte en est le signal de fin.
const nextCursor = hasQuery ? null : items.length > 0 ? String(page + 1) : null;
return { items, nextCursor };
} catch (err) {
// Rethrown as-is (already keyed "750g" by whichever branch above
// threw it) — this adapter's only caller (`sources.service.ts`)
// already handles/logs failures centrally; this method just isn't
// allowed a bare `async` body without a try/catch per the repo's
// convention. Same reasoning as `marmiton.ts`/`json-ld-recipe.ts`.
throw err;
}
},
// `externalId` est directement l'URL canonique de la recette sur
// 750g.com (renvoyée telle quelle par `list()` ci-dessus) — même
// convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
// délègue entièrement : la réparation du JSON-LD (voir
// `sanitizeJsonLdBlocks`) n'a lieu qu'à l'étape `parse()`, pas ici.
async fetchDetail(externalId: string): Promise<{ html: string; url: string }> {
try {
return await jsonLdRecipeAdapter.fetchDetail(externalId);
} catch (err) {
// Pas un simple re-throw : `rekeySourceError` est le traitement utile
// que ce point d'appel doit faire de l'erreur (relabelliser sa
// `sourceKey`), conformément à la convention await/try-catch du repo.
throw rekeySourceError(err);
}
},
parse(raw: { html: string; url: string }): ParsedRecipe {
try {
const sanitizedHtml = sanitizeJsonLdBlocks(raw.html);
const parsed = jsonLdRecipeAdapter.parse({ html: sanitizedHtml, url: raw.url });
return decodeParsedRecipeText(parsed);
} catch (err) {
throw rekeySourceError(err);
}
},
};

View file

@ -1,15 +1,12 @@
import { registerRecipeSource } from "../lib/recipe-sources/recipe-source-registry.js";
import { sevenFiftyGAdapter } from "./750g.js";
import { mangerBougerAdapter } from "./manger-bouger.js";
import { marmitonAdapter } from "./marmiton.js";
import { theMealDbAdapter } from "./the-meal-db.js";
/**
* Registers every concrete, *browsable* `RecipeSourceAdapter` this app
* ships with into the shared in-memory registry (`recipe-source-registry.ts`)
* `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter` and
* `mangerBougerAdapter`. Called once, explicitly, by the two real entry
* points that need the registry populated:
* ships with into the shared in-memory registry
* (`recipe-source-registry.ts`) currently just `theMealDbAdapter`.
* Called once, explicitly, by the two real entry points that need the
* registry populated:
*
* - `server.ts` the running API process, before it starts listening.
* - `prisma/seed.ts` so `syncRecipeSources` has something to mirror into
@ -24,19 +21,15 @@ import { theMealDbAdapter } from "./the-meal-db.js";
* explicit setup. Tests that need a source in the registry register their
* own throwaway fake instead (see e.g. `test/recipe-source-sync.test.ts`).
*
* `jsonLdRecipeAdapter` (json-ld-recipe.ts) itself is deliberately **not**
* `jsonLdRecipeAdapter` (json-ld-recipe.ts) is deliberately **not**
* registered here it's a generic schema.org-JSON-LD parser meant to be
* specialized per scraped website, not a household-toggleable `Source` in
* its own right: nobody can meaningfully "trust" or "enable" a generic
* parsing mechanism the way they can a named website. `marmitonAdapter`
* (marmiton.ts), `sevenFiftyGAdapter` (750g.ts) and `mangerBougerAdapter`
* (manger-bouger.ts) are exactly that specialization, one per site the
* concrete adapters its own doc comment anticipated ("a concrete adapter
* for a specific site would use it internally").
* specialized per scraped website (a concrete adapter for a specific site
* would use it internally), not a household-toggleable `Source` in its own
* right: nobody can meaningfully "trust" or "enable" a generic parsing
* mechanism the way they can a named website. Until real per-site adapters
* exist, it's called directly (e.g. a future "import from a pasted URL"
* flow), never through this registry.
*/
export function registerAllRecipeSources(): void {
registerRecipeSource(theMealDbAdapter);
registerRecipeSource(marmitonAdapter);
registerRecipeSource(sevenFiftyGAdapter);
registerRecipeSource(mangerBougerAdapter);
}

View file

@ -44,17 +44,8 @@ interface SchemaOrgRecipe {
url?: string;
}
/**
* Extracts and JSON-parses every JSON-LD block on the page a block that
* fails to parse is skipped rather than failing the whole page over one
* malformed script tag (some sites ship more than one JSON-LD block, e.g.
* `BreadcrumbList` alongside `Recipe`). Exported (not just consumed
* internally by {@link findRecipeNode} below) so a concrete per-site adapter
* built on top of this module e.g. `marmiton.ts`, which needs the same
* page's embedded `ItemList` rather than its `Recipe` reuses this same
* extraction step instead of re-implementing the `<script>`-block regex.
*/
export function extractJsonLdBlocks(html: string): unknown[] {
/** Extracts and JSON-parses every JSON-LD block on the page — a block that fails to parse is skipped rather than failing the whole page over one malformed script tag (some sites ship more than one JSON-LD block, e.g. `BreadcrumbList` alongside `Recipe`). */
function extractJsonLdBlocks(html: string): unknown[] {
const blocks: unknown[] = [];
for (const match of html.matchAll(JSON_LD_SCRIPT_PATTERN)) {
try {
@ -176,17 +167,6 @@ function flattenInstructions(instructions: SchemaOrgRecipe["recipeInstructions"]
* `fetchDetail`'s `externalId` is simply the target URL itself, not an id
* from a prior `list()` call. A future "import from URL" flow would call
* `fetchDetail(pastedUrl)` directly.
*
* `marmiton.ts`'s `marmitonAdapter` is the first concrete adapter built on
* top of this one its `fetchDetail`/`parse` delegate straight here (a
* marmiton.org recipe page's JSON-LD is a plain schema.org `Recipe`, nothing
* site-specific to handle), and it only adds the `list()` this adapter
* itself can't offer, by reading the separate `ItemList` marmiton.org embeds
* on its search-results pages. `750g.ts`'s `sevenFiftyGAdapter` and
* `manger-bouger.ts`'s `mangerBougerAdapter` follow the same shape for their
* own sites, but each wraps this adapter's own `parse()` (rather than
* delegating untouched) to work around real bugs/gaps in that site's own
* JSON-LD see each module's doc comment.
*/
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{
html: string;

View file

@ -1,355 +0,0 @@
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListItem,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
import { jsonLdRecipeAdapter } from "./json-ld-recipe.js";
const SOURCE_KEY = "mangerBouger";
// "La Fabrique à Menus" — mangerbouger.fr's recipe tool (Santé publique
// France). Its listing page is a Next.js app with no JSON-LD `ItemList` at
// all (unlike marmiton.ts's search page) — but it's server-rendered, and a
// plain GET carries the exact same Redux state the client hydrates from as
// a `__NEXT_DATA__` script tag (see `extractNextData` below), which already
// has everything `list()` needs. Verified live: `?query=<free text>` really
// filters server-side (not just a client-side URL update over an
// already-fetched page), and `page`/`hasMorePages` behave as real,
// consistent pagination — the best-behaved of this adapter family's three
// sources on that front.
const LIST_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes";
const DETAIL_BASE_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes/";
/** Matches the `<script id="__NEXT_DATA__">…</script>` block every Next.js page ships — the site's own server-rendered hydration data, read instead of scraping HTML for both `list()` (the listing's recipe cards) and `parse()` (backfilling a gap in the detail page's JSON-LD, see {@link extractPortionsFromNextData}). */
const NEXT_DATA_PATTERN = /<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/;
/** The one field of one `list[]` entry `list()` actually reads off the listing page's `__NEXT_DATA__` — that state carries the site's full internal `Recipe` shape (60+ fields: nutriscore, seasons, macros, …), none of which this adapter's contract has anywhere to put. */
interface MangerBougerListEntry {
slug?: string;
name?: string;
image?: string | null;
}
/** The slice of `__NEXT_DATA__` this module reads off the *listing* page. */
interface MangerBougerListPageData {
props?: {
initialState?: {
recipes?: {
list?: MangerBougerListEntry[];
/** Whether a further page exists for the current `page`/`query`/`diet` combination — verified live: an out-of-range page comes back `false` with an empty `list` rather than repeating the last page or erroring, a cleaner end-of-results signal than either `marmiton.ts` (infers it from a 404) or `750g.ts` (this search has no real pagination at all). */
hasMorePages?: boolean;
};
};
};
}
/** The slice of `__NEXT_DATA__` this module reads off a recipe *detail* page — a different shape than the listing page's (`initialState.recipe.recipe`, not `initialState.recipes.list[]`) since it's a different Redux slice entirely. */
interface MangerBougerDetailPageData {
props?: {
initialState?: {
recipe?: {
recipe?: {
portions?: unknown;
};
};
};
};
}
/** Parses the page's `__NEXT_DATA__` block into `T`, or `null` if the block is missing or isn't valid JSON — callers degrade gracefully rather than throw, same as `marmiton.ts`'s "page has no ItemList at all" handling. */
function extractNextData<T>(html: string): T | null {
const match = html.match(NEXT_DATA_PATTERN);
if (!match) return null;
try {
return JSON.parse(match[1] ?? "") as T;
} catch {
return null;
}
}
function detailUrl(slug: string): string {
return `${DETAIL_BASE_URL}${slug}`;
}
/**
* Matches the single `<script type="application/ld+json">…</script>` block
* a mangerbouger.fr recipe *detail* page carries (verified live across a
* sample of 9 recipes always exactly one, always a bare `Recipe`, never
* an `@graph`) a much narrower pattern than `json-ld-recipe.ts`'s own
* `JSON_LD_SCRIPT_PATTERN` (no `g` flag: this module only ever needs the
* first/only block, to patch it see {@link patchRecipeJsonLd}) or
* `750g.ts`'s identically-named private copy (which does its own,
* different, character-level repair over every block on the page).
*/
const JSON_LD_SCRIPT_PATTERN =
/(<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>)([\s\S]*?)(<\/script>)/i;
/** One node of a Slate.js rich-text document — see {@link flattenSlateDocument}. */
interface SlateNode {
type?: string;
text?: string;
children?: SlateNode[];
}
/** Concatenates a run of inline Slate nodes (leaf text, or further-nested inline runs) with no separator — bold/italic/underline marks (the only ones observed) carry no plain-text equivalent and are simply dropped. */
function flattenSlateInline(nodes: SlateNode[]): string {
return nodes
.map((node) =>
typeof node.text === "string"
? node.text
: node.children
? flattenSlateInline(node.children)
: "",
)
.join("");
}
/**
* Flattens a Slate.js document's top-level blocks into one line of plain
* text each verified live across every recipe step sampled (72 recipes):
* only `paragraph` and `bulleted-list` (of `list-item`s) ever appear as
* block types, so that's all this handles; any other/unrecognized block
* type still degrades reasonably (its own children read as one inline run)
* rather than being dropped outright.
*/
function flattenSlateBlocks(nodes: SlateNode[]): string[] {
const lines: string[] = [];
for (const node of nodes) {
if (node.type === "bulleted-list" && node.children) {
lines.push(...flattenSlateBlocks(node.children));
continue;
}
if (node.type === "list-item" && node.children) {
const text = flattenSlateInline(node.children);
if (text.trim().length > 0) lines.push(`- ${text}`);
continue;
}
if (node.children) {
const text = flattenSlateInline(node.children);
if (text.trim().length > 0) lines.push(text);
continue;
}
if (typeof node.text === "string" && node.text.trim().length > 0) lines.push(node.text);
}
return lines;
}
/**
* Flattens one `HowToStep.text` value into plain text. mangerbouger.fr's
* own JSON-LD embeds this field pre-formatted for its own web app instead
* of as prose: `text` is itself a JSON-serialized Slate.js rich-text
* document (verified live: every one of 72 sampled recipe steps parses as
* one) handing that straight to `jsonLdRecipeAdapter.parse` would surface
* the raw `[{"type":"paragraph","children":[{"text":"…` blob as a step's
* description, unusable as-is. `json` that doesn't parse as an array (a
* genuinely plain-text step, or some future/different shape) is returned
* unchanged rather than mangled.
*/
function flattenSlateDocument(json: string): string {
let doc: unknown;
try {
doc = JSON.parse(json);
} catch {
return json;
}
if (!Array.isArray(doc)) return json;
return flattenSlateBlocks(doc as SlateNode[]).join("\n");
}
/** The two schema.org `Recipe` fields {@link patchRecipeJsonLd} patches, plus an index signature so every other field survives re-serialization untouched. */
interface JsonLdRecipeLike {
recipeInstructions?: unknown;
recipeYield?: unknown;
[key: string]: unknown;
}
/** One `HowToStep`-shaped entry of `recipeInstructions`, as far as {@link patchRecipeJsonLd} needs to know. */
interface JsonLdHowToStepLike {
text?: unknown;
[key: string]: unknown;
}
/**
* `state.recipe.recipe.portions` from the same detail page's `__NEXT_DATA__`
* the number `recipeYield` should have been (see {@link patchRecipeJsonLd}),
* read from the site's own internal state rather than left unstated.
*/
function extractPortionsFromNextData(html: string): number | null {
const data = extractNextData<MangerBougerDetailPageData>(html);
const portions = data?.props?.initialState?.recipe?.recipe?.portions;
return typeof portions === "number" ? portions : null;
}
/**
* Repairs the two real gaps verified live in mangerbouger.fr's own
* recipe-detail JSON-LD, then hands the patched HTML to
* `jsonLdRecipeAdapter.parse` unmodified otherwise same "fix what's
* actually broken, delegate the rest" shape as `750g.ts`'s
* `sanitizeJsonLdBlocks`/`decodeParsedRecipeText`, just structural (parse
* mutate re-serialize the one JSON-LD object) rather than textual, since
* both gaps need real understanding of the document, not character-level
* fixups:
*
* - `recipeInstructions[].text` is Slate.js rich text, not prose flattened
* via {@link flattenSlateDocument}.
* - `recipeYield` is absent on every one of 9 sampled recipes (schema.org
* allows omitting it, and mangerbouger.fr's generator apparently always
* does) even though the site's own internal data has the serving count
* right there backfilled from `__NEXT_DATA__` via
* {@link extractPortionsFromNextData} rather than left as a needless
* `portions: null` on every single imported recipe.
*
* A missing or malformed JSON-LD block is left completely untouched
* `jsonLdRecipeAdapter`'s own "no JSON-LD Recipe found"/"malformed block,
* skip it" handling is exactly the right behavior for that, no need to
* duplicate it here.
*/
function patchRecipeJsonLd(html: string): string {
const match = html.match(JSON_LD_SCRIPT_PATTERN);
if (!match) return html;
let recipe: JsonLdRecipeLike;
try {
recipe = JSON.parse(match[2] ?? "{}") as JsonLdRecipeLike;
} catch {
return html;
}
if (Array.isArray(recipe.recipeInstructions)) {
for (const step of recipe.recipeInstructions as JsonLdHowToStepLike[]) {
if (step && typeof step === "object" && typeof step.text === "string") {
step.text = flattenSlateDocument(step.text);
}
}
}
if (recipe.recipeYield === undefined) {
const portions = extractPortionsFromNextData(html);
if (portions !== null) recipe.recipeYield = portions;
}
const patchedJson = JSON.stringify(recipe);
return html.replace(
JSON_LD_SCRIPT_PATTERN,
(_full, openTag: string, _json: string, closeTag: string) =>
`${openTag}${patchedJson}${closeTag}`,
);
}
/**
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having
* come from this adapter instead (`sourceKey` `"mangerBouger"`) same
* reasoning as `marmiton.ts`/`750g.ts`'s identically-named helpers.
*/
function rekeySourceError(err: unknown): unknown {
if (err instanceof RecipeSourceFetchError) {
return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause });
}
if (err instanceof RecipeSourceParseError) {
return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause });
}
return err;
}
/**
* mangerbouger.fr ("La Fabrique à Menus") Santé publique France's public
* nutrition site. Unofficial (`official: false`): no published API, same
* reasoning as every other adapter in this family fetching ordinary pages
* and reading data the site never committed to a stable contract, not a
* maintained endpoint. `fetchDetail` delegates straight to
* `jsonLdRecipeAdapter`; `parse` wraps it with {@link patchRecipeJsonLd}
* (see that function's doc comment for the two real gaps it fixes).
* `list()` doesn't use JSON-LD at all — see `LIST_URL`'s doc comment.
*/
export const mangerBougerAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
key: SOURCE_KEY,
name: "Manger Bouger",
official: false,
// Chemin fixe (pas d'icône versionnée/hashée comme sur d'autres sources
// de cette famille) — répond correctement sans paramètre supplémentaire.
iconUrl: "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/favicon.ico",
// Le contenu de mangerbouger.fr (noms, ingrédients, instructions) est en
// français — détermine contre quel modèle/locale d'étiquettes
// d'ingrédients translateRecipe (recipe-translation.ts) résout les
// recettes de cette source lors d'une prévisualisation/d'un import.
locale: "fr",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
const page = params.cursor ? Number(params.cursor) : 1;
const query = params.query ?? "";
const listUrl = `${LIST_URL}?diet=ALL&page=${page}&query=${encodeURIComponent(query)}`;
let response: Response;
try {
response = await fetch(listUrl);
} catch (cause) {
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error listing recipes (${listUrl})`, {
cause,
});
}
if (!response.ok) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`mangerbouger.fr responded ${response.status} (${listUrl})`,
);
}
const html = await response.text();
const data = extractNextData<MangerBougerListPageData>(html);
const state = data?.props?.initialState?.recipes;
const items: RecipeSourceListItem[] = (state?.list ?? [])
.filter((entry): entry is MangerBougerListEntry & { slug: string; name: string } =>
Boolean(entry.slug && entry.name),
)
.map((entry) => ({
externalId: detailUrl(entry.slug),
title: entry.name,
picture: entry.image ?? null,
url: detailUrl(entry.slug),
}));
return { items, nextCursor: state?.hasMorePages ? String(page + 1) : null };
} catch (err) {
// Rethrown as-is (already keyed "mangerBouger" by whichever branch
// above threw it) — this adapter's only caller (`sources.service.ts`)
// already handles/logs failures centrally; this method just isn't
// allowed a bare `async` body without a try/catch per the repo's
// convention. Same reasoning as `marmiton.ts`/`750g.ts`.
throw err;
}
},
// `externalId` est directement l'URL canonique de la recette sur
// mangerbouger.fr (renvoyée telle quelle par `list()` ci-dessus) — même
// convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
// délègue entièrement : la réparation du JSON-LD (voir
// `patchRecipeJsonLd`) n'a lieu qu'à l'étape `parse()`, pas ici.
async fetchDetail(externalId: string): Promise<{ html: string; url: string }> {
try {
return await jsonLdRecipeAdapter.fetchDetail(externalId);
} catch (err) {
// Pas un simple re-throw : `rekeySourceError` est le traitement utile
// que ce point d'appel doit faire de l'erreur (relabelliser sa
// `sourceKey`), conformément à la convention await/try-catch du repo.
throw rekeySourceError(err);
}
},
parse(raw: { html: string; url: string }): ParsedRecipe {
try {
const patchedHtml = patchRecipeJsonLd(raw.html);
return jsonLdRecipeAdapter.parse({ html: patchedHtml, url: raw.url });
} catch (err) {
throw rekeySourceError(err);
}
},
};

View file

@ -1,206 +0,0 @@
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListItem,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
import { extractJsonLdBlocks, jsonLdRecipeAdapter } from "./json-ld-recipe.js";
const SOURCE_KEY = "marmiton";
const SEARCH_URL = "https://www.marmiton.org/recettes/recherche.aspx";
/**
* One `ListItem` inside the schema.org `ItemList` marmiton.org embeds as
* JSON-LD on its search-results pages the subset this adapter reads. Also
* what a search whose term happens to match a known ingredient (e.g.
* `aqt=poulet`) actually returns: marmiton.org silently serves its
* ingredient-index page instead of a "search results" page for those terms,
* but that page embeds the exact same `ItemList` shape, so `list()` doesn't
* need to tell the two apart.
*/
interface MarmitonListItem {
"@type"?: string;
url?: string;
name?: string;
image?: string;
}
/** The subset of a schema.org `ItemList` this adapter reads off marmiton.org's search-results page. */
interface MarmitonItemList {
"@type"?: string;
"@graph"?: unknown[];
itemListElement?: MarmitonListItem[];
}
/**
* Finds the first `ItemList` node within one parsed JSON-LD block mirrors
* `findRecipeNode`'s traversal in json-ld-recipe.ts (array of mixed-type
* nodes, `@graph` wrapper) but looks for the results listing marmiton.org's
* search page embeds instead of a `Recipe`.
*/
function findItemListNode(node: unknown): MarmitonItemList | null {
if (node === null || typeof node !== "object") return null;
if (Array.isArray(node)) {
for (const item of node) {
const found = findItemListNode(item);
if (found) return found;
}
return null;
}
const obj = node as MarmitonItemList;
if (obj["@type"] === "ItemList") return obj;
if (Array.isArray(obj["@graph"])) return findItemListNode(obj["@graph"]);
return null;
}
/**
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having
* come from this adapter instead (`sourceKey` `"marmiton"`). `fetchDetail`/
* `parse` below are thin wrappers around the generic adapter's own methods
* (see this module's doc comment) but a caller catching `RecipeSourceError`
* and reading `.sourceKey` to attribute a failure to a specific `Source`
* should see "marmiton", the source it actually asked about, not the
* internal implementation detail this adapter happens to be built on.
* Anything else (a bug, an unexpected throw) is passed through unchanged
* only the vocabulary this module documents gets relabeled.
*/
function rekeySourceError(err: unknown): unknown {
if (err instanceof RecipeSourceFetchError) {
return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause });
}
if (err instanceof RecipeSourceParseError) {
return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause });
}
return err;
}
/**
* marmiton.org France's largest recipe site. Unofficial (`official:
* false`): there's no published API, this adapter fetches ordinary pages and
* reads the schema.org structured data marmiton.org embeds for search
* engines, same as {@link jsonLdRecipeAdapter} it's built on. It's the first
* concrete, per-site adapter that generic adapter's own doc comment
* anticipated ("a concrete adapter for a specific site would use it
* internally") `fetchDetail`/`parse` below just delegate straight to it,
* since a marmiton.org recipe page's JSON-LD is a plain schema.org `Recipe`
* with nothing site-specific to handle. The only real Marmiton-specific
* logic is `list()`: `jsonLdRecipeAdapter` has no catalog of its own to
* browse, but marmiton.org's search-results page embeds a browsable
* `ItemList` this adapter reads directly (see {@link findItemListNode}).
*/
export const marmitonAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
key: SOURCE_KEY,
name: "Marmiton",
official: false,
// Un chemin stable (jamais un nom de fichier avec un hash de build, comme
// les icônes servies depuis statics.marmiton.fr) — marmiton.org sert son
// favicon à cette adresse indépendamment de tout déploiement.
iconUrl: "https://www.marmiton.org/favicon.ico",
// Le contenu de Marmiton (noms, ingrédients, instructions) est en
// français — détermine contre quel modèle/locale d'étiquettes
// d'ingrédients translateRecipe (recipe-translation.ts) résout les
// recettes de cette source lors d'une prévisualisation/d'un import.
locale: "fr",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
// Un curseur opaque qui encode simplement le numéro de page suivant —
// marmiton.org pagine sa recherche via `&page=N` (page 1 implicite
// quand le paramètre est absent), pas de token dédié à faire
// transiter.
const page = params.cursor ? Number(params.cursor) : 1;
const query = params.query ?? "";
const searchUrl = `${SEARCH_URL}?aqt=${encodeURIComponent(query)}${
page > 1 ? `&page=${page}` : ""
}`;
let response: Response;
try {
response = await fetch(searchUrl);
} catch (cause) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Network error searching Marmiton (${searchUrl})`,
{ cause },
);
}
// marmiton.org répond 404 dès que `page` dépasse la dernière page de
// résultats pour cette recherche — pas un vrai échec, juste "il n'y a
// plus rien" : son `ItemList` ne porte aucun total fiable (son
// `numberOfItems` vaut toujours la taille de la page courante, jamais
// le nombre total de résultats) pour le détecter à l'avance autrement
// qu'en demandant la page suivante et en constatant qu'elle est vide.
if (response.status === 404) {
return { items: [], nextCursor: null };
}
if (!response.ok) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Marmiton search responded ${response.status} (${searchUrl})`,
);
}
const html = await response.text();
let itemList: MarmitonItemList | null = null;
for (const block of extractJsonLdBlocks(html)) {
itemList = findItemListNode(block);
if (itemList) break;
}
const items: RecipeSourceListItem[] = (itemList?.itemListElement ?? [])
.filter((entry): entry is MarmitonListItem & { url: string; name: string } =>
Boolean(entry.url && entry.name),
)
.map((entry) => ({
externalId: entry.url,
title: entry.name,
picture: entry.image ?? null,
url: entry.url,
}));
return {
items,
// Voir le commentaire ci-dessus sur la réponse 404 : une page vide
// est elle-même le signal de fin, donc on ne propose une page
// suivante que si celle-ci en a retourné au moins un résultat.
nextCursor: items.length > 0 ? String(page + 1) : null,
};
} catch (err) {
// Rethrown as-is (already keyed "marmiton" by whichever branch above
// threw it) — this adapter's only caller (`sources.service.ts`)
// already handles/logs failures centrally; this method just isn't
// allowed a bare `async` body without a try/catch per the repo's
// convention. Same reasoning as `json-ld-recipe.ts`/`the-meal-db.ts`.
throw err;
}
},
// `externalId` est directement l'URL canonique de la recette sur
// marmiton.org (renvoyée telle quelle par `list()` ci-dessus) — même
// convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
// délègue entièrement (voir le commentaire du module).
async fetchDetail(externalId: string): Promise<{ html: string; url: string }> {
try {
return await jsonLdRecipeAdapter.fetchDetail(externalId);
} catch (err) {
// Pas un simple re-throw : `rekeySourceError` est le traitement utile
// que ce point d'appel doit faire de l'erreur (relabelliser sa
// `sourceKey`), conformément à la convention await/try-catch du repo.
throw rekeySourceError(err);
}
},
parse(raw: { html: string; url: string }): ParsedRecipe {
try {
return jsonLdRecipeAdapter.parse(raw);
} catch (err) {
throw rekeySourceError(err);
}
},
};

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

@ -1,306 +0,0 @@
import { ErrorCode } from "@batch-cooking/shared";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../../src/app.js";
import { env } from "../../src/config/env.js";
import { prisma } from "../../src/db/prisma.js";
import { resetDatabase } from "../../test-support/reset-db.js";
const SECRET_HEADER = "X-Internal-Worker-Secret";
/** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — mirrors `recipe.test.ts`'s own `techStepId` helper. */
async function techStepId(key: string): Promise<number> {
const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } });
return techStep.id;
}
/** A minimal author + recipe + step fixture — these routes have no notion of a session/viewer, so nothing here needs to go through `/auth/signup` the way `recipe.test.ts`'s fixtures do. */
async function createRecipeWithStep(
description = "Faire mijoter la sauce.",
): Promise<{ stepId: number; recipeId: number }> {
const author = await prisma.userProfile.create({
data: {
firstName: "Test",
lastName: "Author",
email: `${crypto.randomUUID()}@example.test`,
passwordHash: "not-a-real-hash",
},
});
const recipe = await prisma.recipe.create({
data: {
name: "Recette",
authorId: author.id,
portions: 4,
steps: { create: [{ description, order: 0 }] },
},
include: { steps: true },
});
const step = recipe.steps[0];
if (!step) throw new Error("expected the fixture recipe to have one step");
return { stepId: step.id, recipeId: recipe.id };
}
describe("Internal tech-step worker routes", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("requireInternalWorker", () => {
it("rejects a request with no secret header with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/internal/tech-steps/audit-batch");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a request with the wrong secret with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app)
.get("/internal/tech-steps/audit-batch")
.set(SECRET_HEADER, "definitely-not-the-right-secret");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects even the correct secret with 401 NOT_AUTHENTICATED on a plain user-facing route (no bypass of requireAuth)", async () => {
const res = await request(app)
.get("/recipes")
.query({ tab: "publique" })
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET ?? "irrelevant-unset-in-this-env");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
});
// Every test below needs a real configured secret to exercise the success
// path — skipped (not failed) in an environment that hasn't set one, same
// "optional, but the surface fails closed without it" posture
// `INTERNAL_WORKER_SECRET` itself has (see config/env.ts). Both this
// repo's `.env.test.example` and `.github/workflows/ci.yml` set one, so
// this only actually skips in an environment that deliberately diverges
// from both.
describe("with a configured secret", () => {
before(function skipWithoutConfiguredSecret() {
if (env.INTERNAL_WORKER_SECRET === undefined) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed without @types/mocha (not a dependency here) — same untyped-`this` shape a plain JS mocha callback would have.
(this as any).skip();
}
});
function withSecret(req: request.Test): request.Test {
return req.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string);
}
describe("GET /internal/tech-steps/audit-batch", () => {
// A true low-confidence positive case can't be asserted here without
// a live trained classifier to verify the exact sentence against
// first — same limitation `tech-step-eval-dataset.ts` documents for
// the same reason (no local Postgres was reachable in the session
// that introduced this file). This test instead covers the
// deterministic negative: a step the classifier confidently resolves
// (proven by `tech-step-matcher.test.ts`'s own identical-sentence
// case) must produce zero audit entries — nothing here should ever
// flag a confident match as worth a second opinion.
it("finds nothing to audit in a step the classifier confidently resolves", async () => {
await createRecipeWithStep("Faire mijoter à feu doux");
const res = await withSecret(
request(app).get("/internal/tech-steps/audit-batch").query({ locale: "fr" }),
);
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
it("finds nothing to audit in a step naming no technique at all", async () => {
await createRecipeWithStep("Ranger les couverts dans le tiroir");
const res = await withSecret(
request(app).get("/internal/tech-steps/audit-batch").query({ locale: "fr" }),
);
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
it("rejects a non-positive limit with 400 VALIDATION_ERROR", async () => {
const res = await withSecret(
request(app).get("/internal/tech-steps/audit-batch").query({ limit: 0 }),
);
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
});
describe("GET /internal/tech-steps/pending-corrections", () => {
it("returns unconsumed corrections, oldest first, excluding already-consumed ones", async () => {
const { stepId, recipeId } = await createRecipeWithStep();
const simmerId = await techStepId("simmer");
const author = await prisma.recipe
.findUniqueOrThrow({ where: { id: recipeId } })
.then((recipe) => recipe.authorId);
const older = await prisma.stepTechStepCorrection.create({
data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
});
const consumed = await prisma.stepTechStepCorrection.create({
data: {
stepId,
correctorId: author,
start: 0,
end: 5,
correctedTechStepId: simmerId,
consumedAt: new Date(),
},
});
const newer = await prisma.stepTechStepCorrection.create({
data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
});
const res = await withSecret(request(app).get("/internal/tech-steps/pending-corrections"));
expect(res.status).to.equal(200);
const ids = (res.body as Array<{ id: number }>).map((entry) => entry.id);
expect(ids).to.deep.equal([older.id, newer.id]);
expect(ids).to.not.include(consumed.id);
});
it("respects ?limit=", async () => {
const { stepId, recipeId } = await createRecipeWithStep();
const simmerId = await techStepId("simmer");
const author = await prisma.recipe
.findUniqueOrThrow({ where: { id: recipeId } })
.then((recipe) => recipe.authorId);
await prisma.stepTechStepCorrection.createMany({
data: [
{ stepId, correctorId: author, start: 0, end: 5, correctedTechStepId: simmerId },
{ stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
],
});
const res = await withSecret(
request(app).get("/internal/tech-steps/pending-corrections").query({ limit: 1 }),
);
expect(res.status).to.equal(200);
expect(res.body).to.have.length(1);
});
});
describe("POST /internal/tech-steps/training-suggestions", () => {
it("creates a suggestion and marks its source correction consumed", async () => {
const { stepId, recipeId } = await createRecipeWithStep();
const simmerId = await techStepId("simmer");
const author = await prisma.recipe
.findUniqueOrThrow({ where: { id: recipeId } })
.then((recipe) => recipe.authorId);
const correction = await prisma.stepTechStepCorrection.create({
data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
});
const res = await withSecret(
request(app)
.post("/internal/tech-steps/training-suggestions")
.send({
suggestions: [
{
techStepKey: "simmer",
locale: "fr",
suggestedSynonyms: ["frémissonner"],
suggestedUtterances: ["laisser frémissonner à feu très doux"],
sourceType: "correction",
sourceCorrectionId: correction.id,
},
],
}),
);
expect(res.status).to.equal(201);
expect(res.body).to.deep.equal({ created: 1 });
const suggestions = await prisma.techStepTrainingSuggestion.findMany({
where: { techStepId: simmerId },
});
expect(suggestions).to.have.length(1);
expect(suggestions[0]?.sourceCorrectionId).to.equal(correction.id);
expect(suggestions[0]?.status).to.equal("pending");
const updatedCorrection = await prisma.stepTechStepCorrection.findUniqueOrThrow({
where: { id: correction.id },
});
expect(updatedCorrection.consumedAt).to.not.equal(null);
});
it("accepts an llm_audit suggestion with no sourceCorrectionId", async () => {
const res = await withSecret(
request(app)
.post("/internal/tech-steps/training-suggestions")
.send({
suggestions: [
{
techStepKey: "boil",
locale: "fr",
suggestedSynonyms: ["bouillonner"],
suggestedUtterances: [],
sourceType: "llm_audit",
},
],
}),
);
expect(res.status).to.equal(201);
expect(res.body).to.deep.equal({ created: 1 });
});
it("rejects sourceType 'correction' with no sourceCorrectionId with 400 VALIDATION_ERROR", async () => {
const res = await withSecret(
request(app)
.post("/internal/tech-steps/training-suggestions")
.send({
suggestions: [
{
techStepKey: "boil",
locale: "fr",
suggestedSynonyms: ["bouillonner"],
suggestedUtterances: [],
sourceType: "correction",
},
],
}),
);
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects an unknown techStepKey with 404 TECH_STEP_NOT_FOUND", async () => {
const res = await withSecret(
request(app)
.post("/internal/tech-steps/training-suggestions")
.send({
suggestions: [
{
techStepKey: "not-a-real-tech-step",
locale: "fr",
suggestedSynonyms: [],
suggestedUtterances: [],
sourceType: "llm_audit",
},
],
}),
);
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND);
});
});
});
});

View file

@ -1,9 +1,8 @@
import { INGREDIENT_LABEL_SYNONYMS_EN, INGREDIENT_LABEL_SYNONYMS_FR } from "@batch-cooking/shared";
import { INGREDIENT_LABEL_SYNONYMS_EN } from "@batch-cooking/shared";
import { expect } from "chai";
import { prisma } from "../../src/db/prisma.js";
import {
extractQuantity,
findIngredientMentions,
type IngredientMatchEntry,
loadIngredientCatalog,
loadUnitCatalog,
@ -91,48 +90,6 @@ describe("ingredient-matcher", () => {
const onionB: IngredientMatchEntry = { ingredientId: 21, label: "Onion" };
expect(matchIngredientName("onion", [onionB, onionA])).to.equal(20);
});
describe("locale: fr", () => {
const carotte: IngredientMatchEntry = { ingredientId: 30, label: "Carotte" };
const poulet: IngredientMatchEntry = { ingredientId: 31, label: "Poulet" };
const blancDePoulet: IngredientMatchEntry = { ingredientId: 32, label: "Blanc de poulet" };
const frCatalog = [carotte, poulet, blancDePoulet];
it("tolerates a regular French plural (a bare 's', unlike English's several suffix patterns)", () => {
// Regression case: French plurals like "carottes" end in "es", which
// the English stemmer's own "es" rule would wrongly strip down to
// "carott" (losing the "e" that's part of the singular "carotte")
// — see stemWordFr's own doc comment. Locale "fr" must use the
// French stemmer instead, or this never matches.
expect(matchIngredientName("carottes", frCatalog, "fr")).to.equal(carotte.ingredientId);
});
it("is accent-insensitive the same way the English path is", () => {
expect(matchIngredientName("CAROTTES", frCatalog, "fr")).to.equal(carotte.ingredientId);
});
it("tolerates extra descriptive words around the match", () => {
expect(matchIngredientName("2 carottes râpées", frCatalog, "fr")).to.equal(
carotte.ingredientId,
);
});
it("prefers the more specific multi-word label over a shorter one it contains", () => {
expect(matchIngredientName("blancs de poulet fermier", frCatalog, "fr")).to.equal(
blancDePoulet.ingredientId,
);
});
it("defaults to the English stemmer when no locale is passed — 'fr' text needs to opt in explicitly", () => {
// Without locale: "fr", "carottes" stems via the English rules
// (endsWith("es") -> strip 2 chars) into "carott", which doesn't
// equal the catalog's own (also English-stemmed) "carotte" — no
// match. This is the exact bug locale-aware stemming fixes; this
// test pins down that the *default* stays exactly as it was for
// every pre-existing English-only caller.
expect(matchIngredientName("carottes", frCatalog)).to.equal(null);
});
});
});
describe("matchUnit", () => {
@ -160,14 +117,10 @@ describe("ingredient-matcher", () => {
expect(matchUnit("TBSP", catalog)).to.equal(tablespoon.unitId);
});
it("ignores trailing text after the unit word", () => {
it("only looks at the first word — ignores trailing text", () => {
expect(matchUnit("cup flour", catalog)).to.equal(cup.unitId);
});
it("also finds the unit word when it isn't first — unlike before French support existed, this is no longer only a first-word check (see the function's own doc comment)", () => {
expect(matchUnit("a heaped tablespoon of sugar", catalog)).to.equal(tablespoon.unitId);
});
it("doesn't match a short abbreviation inside an unrelated word", () => {
// "g" alone must not match "grated" — whole-token comparison.
expect(matchUnit("grated", catalog)).to.equal(null);
@ -184,35 +137,6 @@ describe("ingredient-matcher", () => {
it("returns null for an empty string", () => {
expect(matchUnit("", catalog)).to.equal(null);
});
describe("locale: fr", () => {
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 frCatalog = [gramme, cuillereASoupe];
it("matches a genuinely multi-word synonym — the bug this locale support fixes: the old single-first-token check could never equal a whole multi-word phrase", () => {
expect(matchUnit("cuillères à soupe de farine", frCatalog, "fr")).to.equal(
cuillereASoupe.unitId,
);
});
it("matches a single-word abbreviation the same way English units do", () => {
expect(matchUnit("càs de farine", frCatalog, "fr")).to.equal(cuillereASoupe.unitId);
});
it("is accent-insensitive", () => {
expect(matchUnit("2 CUILLÈRES À SOUPE de farine", frCatalog, "fr")).to.equal(
cuillereASoupe.unitId,
);
});
it("doesn't match a multi-word phrase against unrelated text mentioning the same first word alone", () => {
expect(matchUnit("cuillère de bois", frCatalog, "fr")).to.equal(null);
});
});
});
describe("extractQuantity", () => {
@ -267,100 +191,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();
@ -409,51 +239,5 @@ describe("ingredient-matcher", () => {
const cupEntry = catalog.find((entry) => entry.unitId === cup.id);
expect(cupEntry?.synonyms).to.deep.equal(["cup", "cups"]);
});
it("loads one entry per Ingredient that has a French label, plus one per alternate wording (INGREDIENT_LABEL_SYNONYMS_FR), keyed by real ingredientId", async () => {
const carrot = await prisma.ingredient.findFirstOrThrow({ where: { key: "carrot" } });
const vanillaBean = await prisma.ingredient.findFirstOrThrow({
where: { key: "vanillaBean" },
});
const ingredientCount = await prisma.ingredient.count();
const synonymCount = Object.values(INGREDIENT_LABEL_SYNONYMS_FR).reduce(
(sum, synonyms) => sum + synonyms.length,
0,
);
const catalog = await loadIngredientCatalog("fr");
// Every seeded ingredient has an authored French label too (copied
// from apps/web's fr locale — see catalog-labels-fr.ts's own doc
// comment), so this mirrors the English test above 1:1.
expect(catalog).to.have.length(ingredientCount + synonymCount);
const carrotEntry = catalog.find((entry) => entry.ingredientId === carrot.id);
expect(carrotEntry?.label).to.equal("Carotte");
const vanillaBeanEntries = catalog.filter((entry) => entry.ingredientId === vanillaBean.id);
expect(vanillaBeanEntries.map((entry) => entry.label)).to.deep.equal([
"Vanille (gousse)",
"Gousse de vanille",
]);
});
it("loads one entry per Unit that has French synonyms, keyed by real unitId", async () => {
const cup = await prisma.unit.findFirstOrThrow({ where: { key: "cup" } });
const unitCount = await prisma.unit.count();
const catalog = await loadUnitCatalog("fr");
expect(catalog).to.have.length(unitCount);
const cupEntry = catalog.find((entry) => entry.unitId === cup.id);
expect(cupEntry?.synonyms).to.deep.equal(["tasse", "tasses"]);
});
it("returns an empty catalog for a locale with no label table at all — the DB is still queried, there's just nothing in either table to match a row against", async () => {
const ingredientCatalog = await loadIngredientCatalog("de");
const unitCatalog = await loadUnitCatalog("de");
expect(ingredientCatalog).to.deep.equal([]);
expect(unitCatalog).to.deep.equal([]);
});
});
});

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", () => {
@ -454,47 +453,7 @@ describe("recipe-translation", () => {
]);
});
it("resolves a real Ingredient id from the seeded French catalog, tolerating a regular French plural", async () => {
const carrot = await prisma.ingredient.findFirstOrThrow({ where: { key: "carrot" } });
const recipe: ParsedRecipe = {
...buildParsedRecipe(["Faire mijoter à feu doux"]),
ingredients: [{ rawText: "3 carottes", quantity: null, unit: null, name: "carottes" }],
};
const translated = await translateRecipe(recipe, "fr");
expect(translated.ingredients[0]?.ingredientId).to.equal(carrot.id);
expect(translated.ingredients[0]?.quantity).to.equal(3);
});
it("resolves a real multi-word Unit id from the seeded French catalog (issue: matchUnit used to only ever compare a single word)", async () => {
const wheatFlour = await prisma.ingredient.findFirstOrThrow({ where: { key: "wheatFlour" } });
const tablespoon = await prisma.unit.findFirstOrThrow({ where: { key: "tablespoon" } });
const recipe: ParsedRecipe = {
...buildParsedRecipe(["Faire mijoter à feu doux"]),
ingredients: [
{
rawText: "2 cuillères à soupe de farine de blé",
quantity: null,
unit: null,
name: "farine de blé",
},
],
};
const translated = await translateRecipe(recipe, "fr");
expect(translated.ingredients[0]).to.deep.equal({
rawText: "2 cuillères à soupe de farine de blé",
quantity: 2,
unit: null,
name: "farine de blé",
ingredientId: wheatFlour.id,
unitId: tablespoon.id,
});
});
it("still extracts a locale-agnostic quantity even for a locale with no ingredient/unit matching data at all, leaving only the ids null", async () => {
it("leaves ingredients untouched (no quantity extraction either) for a non-English locale — no matching data exists yet, and the DB isn't even queried for it", async () => {
const recipe: ParsedRecipe = {
...buildParsedRecipe(["Faire mijoter à feu doux"]),
ingredients: [
@ -502,12 +461,12 @@ describe("recipe-translation", () => {
],
};
const translated = await translateRecipe(recipe, "de");
const translated = await translateRecipe(recipe, "fr");
expect(translated.ingredients).to.deep.equal([
{
rawText: "1 cup onions, chopped",
quantity: 1,
quantity: null,
unit: null,
name: "onions",
ingredientId: null,

View file

@ -1,37 +0,0 @@
import { expect } from "chai";
import { prisma } from "../../src/db/prisma.js";
import {
MIN_OVERALL_F1,
runTechStepEvalSuite,
} from "../../src/lib/recipe-matching/tech-step-eval-runner.js";
import { resetDatabase } from "../../test-support/reset-db.js";
/**
* Regression gate for `TECH_STEP_TRAINING_DATA` every change to that
* corpus (including a maintainer applying suggestions from
* `TechStepTrainingSuggestion`, see `scripts/retrain-tech-steps.ts`) must
* keep this suite green. Runs {@link runTechStepEvalSuite} (the real
* trained classifier against `tech-step-eval-dataset.ts`) and asserts the
* aggregate F1 doesn't fall below {@link MIN_OVERALL_F1} see that
* constant's own doc comment (`tech-step-eval-runner.ts`) for the real run
* it was calibrated against.
*/
describe("tech-step-eval", () => {
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
it(`scores at least ${MIN_OVERALL_F1} aggregate F1 against the labeled evaluation set`, async () => {
const { overall, byKey } = await runTechStepEvalSuite();
expect(
overall.f1,
`aggregate F1 ${overall.f1.toFixed(3)} (precision ${overall.precision.toFixed(3)}, recall ${overall.recall.toFixed(3)}) fell below the ${MIN_OVERALL_F1} floor — per-technique breakdown: ${JSON.stringify(byKey)}`,
).to.be.at.least(MIN_OVERALL_F1);
});
});

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,36 +132,18 @@ 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([
prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "cook" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "bake" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "preheat" } }),
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" } }),
]);
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" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "preheat" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "melt" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "boil" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "chop" } }),
]);
simmerId = simmer.id;
cookId = cook.id;
bakeId = bake.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

@ -1,341 +0,0 @@
import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { sevenFiftyGAdapter } from "../../src/sources/750g.js";
/** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as `json-ld-recipe.test.ts`'s `stubFetchHtml`. */
function stubFetchHtml(html: string, status = 200) {
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}
const RECIPE_URL = "https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm";
/**
* A raw (not `JSON.stringify`-escaped) JSON-LD `Recipe` payload, deliberately
* reproducing two real 750g.com bugs verified live on the recipe this test's
* URL/content is modeled after:
* - a literal, unescaped `\r\n` inside `recipeInstructions[0].text` (invalid
* JSON as-is this is exactly what {@link sanitizeJsonLdBlocks} in the
* adapter under test has to repair before `JSON.parse` can succeed);
* - `Pr&amp;eacute;parez` a real "é" that went through 750g's own
* HTML-entity encoder twice (`decodeHtmlEntities` has to run twice to
* fully resolve it back to "é").
* Plus a plain `&#039;` apostrophe entity in an ingredient line, the more
* common single-encoding case.
*/
const RAW_RECIPE_JSON_LD = `{
"@context": "https://schema.org",
"@type": "Recipe",
"name": "Poulet au vin jaune et aux morilles",
"description": "Une recette de f\\u00eate.",
"image": {"@type": "ImageObject", "url": "https://static.750g.com/images/poulet-vin-jaune.jpg"},
"recipeYield": "6 personnes",
"recipeIngredient": ["1 poulet fermier", "Sel &#039;fin&#039;"],
"recipeInstructions": [
{"@type": "HowToStep", "text": "Pr&amp;eacute;parez les morilles :\r\nFendez-les en deux."}
],
"url": "${RECIPE_URL}"
}`;
function htmlWithRawJsonLd(rawJson: string): string {
return `<!doctype html><html><head><script type="application/ld+json">${rawJson}</script></head><body></body></html>`;
}
describe("sevenFiftyGAdapter", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("declares itself as an unofficial, French-locale source with an icon", () => {
expect(sevenFiftyGAdapter.key).to.equal("750g");
expect(sevenFiftyGAdapter.name).to.equal("750g");
expect(sevenFiftyGAdapter.official).to.equal(false);
expect(sevenFiftyGAdapter.iconUrl).to.be.a("string");
expect(sevenFiftyGAdapter.locale).to.equal("fr");
});
describe("list", () => {
/**
* Models the real shape found live: a card's own `<img>` sits
* immediately before its `<a class="card-link">`, but the fragment also
* carries decorative images that belong to no card at all (verified
* live: 28 `<img>` tags against 23 real cards for one sample query)
* an image search that isn't "nearest preceding, not naive same-index
* zip" would misattribute every card after the first stray image.
*/
const CARDS_HTML = `
<div class="grid">
<img src="https://static.750g.com/images/x/orphan-lead.jpg" class="decorative" />
<div class="card">
<img src="https://static.750g.com/images/x/tarte.jpg" alt="Tarte" />
<a href="https://www.750g.com/tarte-aux-pommes-r1.htm" class="card-link ">Tarte aux pommes</a>
</div>
<img src="https://static.750g.com/images/x/orphan-mid-1.jpg" class="decorative" />
<img src="https://static.750g.com/images/x/orphan-mid-2.jpg" class="decorative" />
<div class="card">
<img src="https://static.750g.com/images/x/gratin.jpg" alt="Gratin" />
<a href="https://www.750g.com/gratin-dauphinois-r2.htm" class="card-link ">Gratin dauphinois</a>
</div>
<div class="card">
<a href="https://www.750g.com/pain-perdu-r3.htm" class="card-link ">Pain perdu</a>
</div>
</div>
`;
it("scrapes each card's title/url/image, matching each image to its nearest preceding link and ignoring orphan images", async () => {
stubFetchHtml(CARDS_HTML);
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
expect(result.items).to.deep.equal([
{
externalId: "https://www.750g.com/tarte-aux-pommes-r1.htm",
title: "Tarte aux pommes",
picture: "https://static.750g.com/images/x/tarte.jpg",
url: "https://www.750g.com/tarte-aux-pommes-r1.htm",
},
{
externalId: "https://www.750g.com/gratin-dauphinois-r2.htm",
title: "Gratin dauphinois",
picture: "https://static.750g.com/images/x/gratin.jpg",
url: "https://www.750g.com/gratin-dauphinois-r2.htm",
},
{
externalId: "https://www.750g.com/pain-perdu-r3.htm",
title: "Pain perdu",
picture: null,
url: "https://www.750g.com/pain-perdu-r3.htm",
},
]);
});
it("decodes HTML entities in a card's title", async () => {
stubFetchHtml(
`<a href="https://www.750g.com/tarte-r1.htm" class="card-link ">Tarte aux pommes &#039;reinettes&#039;</a>`,
);
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
expect(result.items[0]?.title).to.equal("Tarte aux pommes 'reinettes'");
});
it("always returns nextCursor: null — this search isn't really paginated (requesting a further page comes back empty)", async () => {
stubFetchHtml(CARDS_HTML);
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
expect(result.nextCursor).to.be.null;
});
it("ignores params.cursor for a text search — always requests page=1, there's never a legitimate cursor for this (non-paginated) endpoint", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response("", { status: 200 });
}) as typeof fetch;
await sevenFiftyGAdapter.list({ query: "tarte", cursor: "7" });
expect(requestedUrl).to.include("page=1");
expect(requestedUrl).not.to.include("page=7");
});
it("URL-encodes the query", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response("", { status: 200 });
}) as typeof fetch;
await sevenFiftyGAdapter.list({ query: "tarte aux pommes" });
expect(requestedUrl).to.include("query=tarte%20aux%20pommes");
});
describe("empty/omitted query (browsing with no filter)", () => {
it("reads 'dernières recettes' instead of the AI search — the search endpoint answers a blank query with nothing at all, which would otherwise make browsing with no filter always come back empty", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(CARDS_HTML, { status: 200 });
}) as typeof fetch;
const result = await sevenFiftyGAdapter.list({});
expect(requestedUrl).to.include("dernieres-recettes.htm");
expect(requestedUrl).not.to.include("genius/query");
expect(result.items).to.have.length(3);
});
it("also browses for an explicitly empty query string, not just an omitted one", async () => {
stubFetchHtml(CARDS_HTML);
const result = await sevenFiftyGAdapter.list({ query: "" });
expect(result.items).to.have.length(3);
});
it("requests the given cursor's page", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(CARDS_HTML, { status: 200 });
}) as typeof fetch;
await sevenFiftyGAdapter.list({ cursor: "5" });
expect(requestedUrl).to.include("page=5");
});
it("offers a next page when the page has cards, and none once a page comes back empty — this endpoint never 404s/redirects past its real end", async () => {
stubFetchHtml(CARDS_HTML);
const withItems = await sevenFiftyGAdapter.list({ cursor: "2" });
expect(withItems.nextCursor).to.equal("3");
stubFetchHtml("<html><body>Plus rien ici</body></html>");
const empty = await sevenFiftyGAdapter.list({ cursor: "50" });
expect(empty.nextCursor).to.be.null;
expect(empty.items).to.deep.equal([]);
});
});
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
stubFetchHtml("", 500);
try {
await sevenFiftyGAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g");
}
});
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
globalThis.fetch = (async () => {
throw new Error("network down");
}) as typeof fetch;
try {
await sevenFiftyGAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
}
});
});
describe("fetchDetail", () => {
it("fetches the given recipe URL and returns its html alongside the url", async () => {
stubFetchHtml(htmlWithRawJsonLd(RAW_RECIPE_JSON_LD));
const result = await sevenFiftyGAdapter.fetchDetail(RECIPE_URL);
expect(result.url).to.equal(RECIPE_URL);
expect(result.html).to.include("Poulet au vin jaune");
});
it("throws a RecipeSourceFetchError keyed to 750g, not the underlying generic adapter", async () => {
stubFetchHtml("", 404);
try {
await sevenFiftyGAdapter.fetchDetail(RECIPE_URL);
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g");
}
});
});
describe("parse", () => {
it("repairs a raw unescaped \\r\\n inside a JSON-LD string that would otherwise fail JSON.parse", () => {
const parsed = sevenFiftyGAdapter.parse({
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
url: RECIPE_URL,
});
expect(parsed.name).to.equal("Poulet au vin jaune et aux morilles");
expect(parsed.steps).to.deep.equal([
{ description: "Préparez les morilles :\r\nFendez-les en deux.", picture: null },
]);
});
it("decodes a double HTML-entity-encoded accented character (é -> &eacute; -> &amp;eacute;)", () => {
const parsed = sevenFiftyGAdapter.parse({
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
url: RECIPE_URL,
});
expect(parsed.steps[0]?.description).to.include("Préparez");
});
it("decodes a plain numeric apostrophe entity in ingredient text", () => {
const parsed = sevenFiftyGAdapter.parse({
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
url: RECIPE_URL,
});
expect(parsed.ingredients).to.deep.equal([
{ rawText: "1 poulet fermier", quantity: null, unit: null, name: "1 poulet fermier" },
{ rawText: "Sel 'fin'", quantity: null, unit: null, name: "Sel 'fin'" },
]);
});
it("leaves picture/sourceUrl untouched by entity decoding", () => {
const parsed = sevenFiftyGAdapter.parse({
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
url: RECIPE_URL,
});
expect(parsed.picture).to.equal("https://static.750g.com/images/poulet-vin-jaune.jpg");
expect(parsed.sourceUrl).to.equal(RECIPE_URL);
});
it("maps a recipe with no quirks end to end, same as the generic adapter would", () => {
const clean = {
"@context": "https://schema.org",
"@type": "Recipe",
name: "Tarte aux pommes",
description: "Une tarte classique.",
image: "https://static.750g.com/images/tarte.jpg",
recipeYield: 6,
recipeIngredient: ["3 pommes", "1 pâte brisée"],
recipeInstructions: ["Éplucher les pommes.", "Enfourner 30 minutes."],
};
const html = `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
clean,
)}</script></head><body></body></html>`;
const parsed = sevenFiftyGAdapter.parse({ html, url: RECIPE_URL });
expect(parsed.name).to.equal("Tarte aux pommes");
expect(parsed.portions).to.equal(6);
expect(parsed.steps).to.deep.equal([
{ description: "Éplucher les pommes.", picture: null },
{ description: "Enfourner 30 minutes.", picture: null },
]);
});
it("throws a RecipeSourceParseError keyed to 750g, not the underlying generic adapter", () => {
const html = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
try {
sevenFiftyGAdapter.parse({ html, url: RECIPE_URL });
expect.fail("expected parse to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceParseError);
expect((err as RecipeSourceParseError).sourceKey).to.equal("750g");
}
});
});
});

View file

@ -1,301 +0,0 @@
import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { mangerBougerAdapter } from "../../src/sources/manger-bouger.js";
/** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as every other adapter test in this family. */
function stubFetchHtml(html: string, status = 200) {
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}
const DETAIL_URL =
"https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes/2854-salade-de-pates-aux-courgettes";
/** Wraps a `props.initialState.recipes` payload (the shape `list()` reads) in a minimal `__NEXT_DATA__` script tag, the same server-rendered hydration data every mangerbouger.fr Next.js page carries. */
function htmlWithListNextData(recipesState: unknown): string {
const payload = { props: { initialState: { recipes: recipesState } } };
return `<!doctype html><html><head></head><body><script id="__NEXT_DATA__" type="application/json">${JSON.stringify(
payload,
)}</script></body></html>`;
}
/**
* A real Slate.js rich-text document (paragraph + bulleted-list of
* list-items, the only block types ever observed live), JSON-stringified
* exactly the shape mangerbouger.fr's own JSON-LD embeds as a `HowToStep`'s
* `text` field.
*/
const SLATE_STEP_DOCUMENT = JSON.stringify([
{ type: "paragraph", children: [{ text: "Cuisson des courgettes", bold: true }] },
{
type: "bulleted-list",
children: [
{ type: "list-item", children: [{ text: "Épluchez les courgettes" }] },
{ type: "list-item", children: [{ text: "Coupez-les en rondelles" }] },
],
},
]);
/** A JSON-LD `Recipe` payload shaped exactly like a real mangerbouger.fr detail page's — no `recipeYield` (verified absent live on every sampled recipe), `recipeInstructions` holding {@link SLATE_STEP_DOCUMENT} instead of prose. */
const RECIPE_JSON_LD_NO_YIELD = {
"@context": "https://schema.org",
"@type": "Recipe",
name: "Salade de pâtes aux courgettes",
image: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg",
recipeIngredient: ["3 Courgette", "4 cuillères à soupe Huile d'olive"],
recipeInstructions: [{ "@type": "HowToStep", name: "Étape 1", text: SLATE_STEP_DOCUMENT }],
url: DETAIL_URL,
};
/** Wraps a JSON-LD `Recipe` payload (already an object, not yet stringified) and, optionally, a `__NEXT_DATA__` detail-page payload carrying `portions`, in one minimal HTML page — the two independent script tags `parse()` reads. */
function htmlWithDetail(recipeJsonLd: unknown, portions?: number): string {
const nextData = portions === undefined ? "" : htmlWithDetailNextDataScript(portions);
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
recipeJsonLd,
)}</script></head><body>${nextData}</body></html>`;
}
function htmlWithDetailNextDataScript(portions: number): string {
const payload = { props: { initialState: { recipe: { recipe: { portions } } } } };
return `<script id="__NEXT_DATA__" type="application/json">${JSON.stringify(payload)}</script>`;
}
describe("mangerBougerAdapter", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("declares itself as an unofficial, French-locale source with an icon", () => {
expect(mangerBougerAdapter.key).to.equal("mangerBouger");
expect(mangerBougerAdapter.name).to.equal("Manger Bouger");
expect(mangerBougerAdapter.official).to.equal(false);
expect(mangerBougerAdapter.iconUrl).to.be.a("string");
expect(mangerBougerAdapter.locale).to.equal("fr");
});
describe("list", () => {
it("maps __NEXT_DATA__'s recipes.list into RecipeSourceListItems", async () => {
stubFetchHtml(
htmlWithListNextData({
list: [
{
id: "2854",
slug: "2854-salade-de-pates-aux-courgettes",
name: "Salade de pâtes aux courgettes",
image: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg",
},
],
hasMorePages: false,
}),
);
const result = await mangerBougerAdapter.list({ query: "salade" });
expect(result.items).to.deep.equal([
{
externalId: DETAIL_URL,
title: "Salade de pâtes aux courgettes",
picture: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg",
url: DETAIL_URL,
},
]);
});
it("offers a next page when hasMorePages is true, and none when false", async () => {
stubFetchHtml(htmlWithListNextData({ list: [], hasMorePages: true }));
expect((await mangerBougerAdapter.list({ query: "x" })).nextCursor).to.equal("2");
stubFetchHtml(htmlWithListNextData({ list: [], hasMorePages: false }));
expect((await mangerBougerAdapter.list({ query: "x" })).nextCursor).to.be.null;
});
it("requests the given cursor's page and URL-encodes the query", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(htmlWithListNextData({ list: [], hasMorePages: false }), {
status: 200,
});
}) as typeof fetch;
await mangerBougerAdapter.list({ query: "crème brûlée", cursor: "3" });
expect(requestedUrl).to.include("page=3");
expect(requestedUrl).to.include("query=cr%C3%A8me%20br%C3%BBl%C3%A9e");
});
it("skips a list entry missing a slug or a name", async () => {
stubFetchHtml(
htmlWithListNextData({
list: [
{ id: "1", name: "No slug", image: null },
{ id: "2", slug: "no-name", image: null },
],
hasMorePages: false,
}),
);
const result = await mangerBougerAdapter.list({ query: "x" });
expect(result.items).to.deep.equal([]);
});
it("returns an empty page rather than throwing when the page has no __NEXT_DATA__ at all", async () => {
stubFetchHtml("<!doctype html><html><body>Rien ici</body></html>");
const result = await mangerBougerAdapter.list({ query: "x" });
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
stubFetchHtml("", 500);
try {
await mangerBougerAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("mangerBouger");
}
});
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
globalThis.fetch = (async () => {
throw new Error("network down");
}) as typeof fetch;
try {
await mangerBougerAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
}
});
});
describe("fetchDetail", () => {
it("fetches the given recipe URL and returns its html alongside the url", async () => {
stubFetchHtml(htmlWithDetail(RECIPE_JSON_LD_NO_YIELD));
const result = await mangerBougerAdapter.fetchDetail(DETAIL_URL);
expect(result.url).to.equal(DETAIL_URL);
expect(result.html).to.include("Salade de p");
});
it("throws a RecipeSourceFetchError keyed to mangerBouger, not the underlying generic adapter", async () => {
stubFetchHtml("", 404);
try {
await mangerBougerAdapter.fetchDetail(DETAIL_URL);
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("mangerBouger");
}
});
});
describe("parse", () => {
it("flattens a Slate.js rich-text step into readable plain text", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD),
url: DETAIL_URL,
});
expect(parsed.steps).to.deep.equal([
{
description:
"Cuisson des courgettes\n- Épluchez les courgettes\n- Coupez-les en rondelles",
picture: null,
},
]);
});
it("backfills recipeYield/portions from __NEXT_DATA__ when the JSON-LD itself doesn't state one", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD, 4),
url: DETAIL_URL,
});
expect(parsed.portions).to.equal(4);
});
it("leaves portions null when __NEXT_DATA__ has no portions to backfill from either", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD),
url: DETAIL_URL,
});
expect(parsed.portions).to.be.null;
});
it("doesn't override recipeYield when the JSON-LD already states one", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail({ ...RECIPE_JSON_LD_NO_YIELD, recipeYield: 8 }, 4),
url: DETAIL_URL,
});
expect(parsed.portions).to.equal(8);
});
it("leaves an already-plain-text step untouched rather than mangling it", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail({
...RECIPE_JSON_LD_NO_YIELD,
recipeInstructions: [{ "@type": "HowToStep", text: "Faites bouillir de l'eau." }],
}),
url: DETAIL_URL,
});
expect(parsed.steps).to.deep.equal([
{ description: "Faites bouillir de l'eau.", picture: null },
]);
});
it("maps name/image/ingredients end to end via the underlying generic adapter", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD),
url: DETAIL_URL,
});
expect(parsed.name).to.equal("Salade de pâtes aux courgettes");
expect(parsed.picture).to.equal(
"https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg",
);
expect(parsed.sourceUrl).to.equal(DETAIL_URL);
expect(parsed.ingredients).to.deep.equal([
{ rawText: "3 Courgette", quantity: null, unit: null, name: "3 Courgette" },
{
rawText: "4 cuillères à soupe Huile d'olive",
quantity: null,
unit: null,
name: "4 cuillères à soupe Huile d'olive",
},
]);
});
it("throws a RecipeSourceParseError keyed to mangerBouger, not the underlying generic adapter", () => {
const html = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
try {
mangerBougerAdapter.parse({ html, url: DETAIL_URL });
expect.fail("expected parse to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceParseError);
expect((err as RecipeSourceParseError).sourceKey).to.equal("mangerBouger");
}
});
});
});

View file

@ -1,226 +0,0 @@
import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { marmitonAdapter } from "../../src/sources/marmiton.js";
/** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as `json-ld-recipe.test.ts`'s `stubFetchHtml`. */
function stubFetchHtml(html: string, status = 200) {
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}
/**
* Wraps a schema.org `ItemList` payload (already an object, not yet
* stringified) in a minimal HTML page carrying it as one
* `<script type="application/ld+json">` block the shape marmiton.org's
* search-results page embeds `list()` reads.
*/
function htmlWithItemListJsonLd(itemListElement: unknown[]): string {
const payload = {
"@context": "https://schema.org",
"@graph": [
{ "@type": "WebSite", name: "Marmiton" },
{
"@type": "ItemList",
"@id": "https://www.marmiton.org/recettes/recherche.aspx?aqt=poulet#itemlist",
numberOfItems: itemListElement.length,
itemListElement,
},
],
};
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
payload,
)}</script></head><body></body></html>`;
}
const RECIPE_URL = "https://www.marmiton.org/recettes/recette_tarte-aux-pommes_11457.aspx";
const baseListItem = {
"@type": "ListItem",
position: 1,
url: RECIPE_URL,
name: "Tarte aux pommes",
image: "https://assets.afcdn.com/recipe/tarte.jpg",
};
const baseRecipeJsonLd = {
"@context": "https://schema.org",
"@type": "Recipe",
name: "Tarte aux pommes",
description: "Une tarte aux pommes classique.",
image: "https://assets.afcdn.com/recipe/tarte.jpg",
recipeYield: "6 personnes",
recipeIngredient: ["3 pommes", "1 pâte brisée"],
recipeInstructions: [
{ "@type": "HowToStep", text: "Épluchez les pommes." },
{ "@type": "HowToStep", text: "Enfournez 30 minutes." },
],
};
function htmlWithRecipeJsonLd(): string {
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
baseRecipeJsonLd,
)}</script></head><body></body></html>`;
}
describe("marmitonAdapter", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("declares itself as an unofficial, French-locale source with an icon", () => {
expect(marmitonAdapter.key).to.equal("marmiton");
expect(marmitonAdapter.name).to.equal("Marmiton");
expect(marmitonAdapter.official).to.equal(false);
expect(marmitonAdapter.iconUrl).to.be.a("string");
expect(marmitonAdapter.locale).to.equal("fr");
});
describe("list", () => {
it("maps the search page's ItemList into RecipeSourceListItems and offers a next page", async () => {
stubFetchHtml(htmlWithItemListJsonLd([baseListItem]));
const result = await marmitonAdapter.list({ query: "tarte aux pommes" });
expect(result.items).to.deep.equal([
{
externalId: RECIPE_URL,
title: "Tarte aux pommes",
picture: "https://assets.afcdn.com/recipe/tarte.jpg",
url: RECIPE_URL,
},
]);
expect(result.nextCursor).to.equal("2");
});
it("requests the given cursor's page and stops offering a next page once a page comes back empty", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(htmlWithItemListJsonLd([]), { status: 200 });
}) as typeof fetch;
const result = await marmitonAdapter.list({ query: "tarte", cursor: "3" });
expect(requestedUrl).to.include("page=3");
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("treats a 404 (page past the last one) as an empty final page, not a failure", async () => {
stubFetchHtml("", 404);
const result = await marmitonAdapter.list({ query: "tarte", cursor: "999" });
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("skips a ListItem missing a url or a name", async () => {
stubFetchHtml(
htmlWithItemListJsonLd([
{ "@type": "ListItem", position: 1, name: "No url" },
{ "@type": "ListItem", position: 2, url: RECIPE_URL },
]),
);
const result = await marmitonAdapter.list({ query: "x" });
expect(result.items).to.deep.equal([]);
});
it("returns an empty page rather than throwing when the page has no ItemList at all", async () => {
stubFetchHtml("<!doctype html><html><body>Rien ici</body></html>");
const result = await marmitonAdapter.list({ query: "x" });
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("throws RecipeSourceFetchError on a non-2xx, non-404 response", async () => {
stubFetchHtml("", 500);
try {
await marmitonAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
}
});
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
globalThis.fetch = (async () => {
throw new Error("network down");
}) as typeof fetch;
try {
await marmitonAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
}
});
});
describe("fetchDetail", () => {
it("fetches the given recipe URL and returns its html alongside the url", async () => {
stubFetchHtml(htmlWithRecipeJsonLd());
const result = await marmitonAdapter.fetchDetail(RECIPE_URL);
expect(result.url).to.equal(RECIPE_URL);
expect(result.html).to.include("Tarte aux pommes");
});
it("throws a RecipeSourceFetchError keyed to marmiton, not the underlying generic adapter", async () => {
stubFetchHtml("", 404);
try {
await marmitonAdapter.fetchDetail(RECIPE_URL);
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("marmiton");
}
});
});
describe("parse", () => {
it("delegates to the generic JSON-LD parser end to end", () => {
const parsed = marmitonAdapter.parse({ html: htmlWithRecipeJsonLd(), url: RECIPE_URL });
expect(parsed.name).to.equal("Tarte aux pommes");
expect(parsed.portions).to.equal(6);
expect(parsed.sourceUrl).to.equal(RECIPE_URL);
expect(parsed.ingredients).to.deep.equal([
{ rawText: "3 pommes", quantity: null, unit: null, name: "3 pommes" },
{ rawText: "1 pâte brisée", quantity: null, unit: null, name: "1 pâte brisée" },
]);
expect(parsed.steps).to.deep.equal([
{ description: "Épluchez les pommes.", picture: null },
{ description: "Enfournez 30 minutes.", picture: null },
]);
});
it("throws a RecipeSourceParseError keyed to marmiton, not the underlying generic adapter", () => {
const html = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
try {
marmitonAdapter.parse({ html, url: RECIPE_URL });
expect.fail("expected parse to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceParseError);
expect((err as RecipeSourceParseError).sourceKey).to.equal("marmiton");
}
});
});
});

View file

@ -1,550 +0,0 @@
import type { SignupInput } from "@batch-cooking/shared";
import { ErrorCode } 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 { resetDatabase } from "../../test-support/reset-db.js";
/** See `auth.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
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 }),
};
}
/** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — mirrors `recipe.test.ts`'s own `techStepId` helper. */
async function techStepId(key: string): Promise<number> {
const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } });
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();
async function signup(): Promise<{ agent: ReturnType<typeof request.agent>; profileId: number }> {
const agent = request.agent(app);
const res = await agent.post("/auth/signup").send(buildSignupPayload());
return { agent, profileId: res.body.id };
}
/** A `PUBLIC` recipe with one step — every viewer can see this, so most tests below don't need to juggle visibility on top of the correction logic itself. */
async function createPublicRecipeWithStep(
authorId: number,
description = "Faire mijoter la sauce.",
): Promise<{ recipeId: number; stepId: number }> {
const recipe = await prisma.recipe.create({
data: {
name: "Recette",
authorId,
visibility: "PUBLIC",
portions: 4,
steps: { create: [{ description, order: 0 }] },
},
include: { steps: true },
});
const step = recipe.steps[0];
if (!step) throw new Error("expected the fixture recipe to have one step");
return { recipeId: recipe.id, stepId: step.id };
}
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("POST /recipes/:id/steps/:stepId/corrections", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const { profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await request(app)
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("records a correction adding a missing technique (no previousTechStepId), and applies it immediately to the step's own techSteps", async () => {
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,
// 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);
const simmerId = await techStepId("simmer");
const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
expect(res.status).to.equal(201);
expect(res.body.correction.previousTechStep).to.equal(null);
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
expect(res.body.correction.start).to.equal(6);
expect(res.body.correction.end).to.equal(13);
// The step's real technique sequence reflects the correction right
// 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: [],
},
]);
});
it("records a correction relabeling an existing match (both ids set), updating the existing techSteps entry in place", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const boilId = await techStepId("boil");
// First correction creates the "manual" entry this test then relabels
// — exercises the UPDATE branch of `applyManualCorrection`, not the
// INSERT one the previous test already covers.
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: boilId });
expect(res.status).to.equal(201);
expect(res.body.correction.previousTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
// 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: [],
},
]);
});
it("deletes the matching techSteps entry when correctedTechStepId is null (a removal)", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
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 });
expect(res.status).to.equal(201);
expect(res.body.correction.correctedTechStep).to.equal(null);
expect(res.body.techSteps).to.deep.equal([]);
});
it("is not restricted to the recipe's author — any viewer who can see it may correct it", async () => {
const { profileId: authorId } = await signup();
const { agent: otherAgent } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(authorId);
const res = await otherAgent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: await techStepId("simmer") });
expect(res.status).to.equal(201);
});
it("rejects both previousTechStepId and correctedTechStepId absent with 400 VALIDATION_ERROR", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 0, end: 5 });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects end <= start with 400 VALIDATION_ERROR", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 5, end: 5, correctedTechStepId: await techStepId("simmer") });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects a 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 + 10,
correctedTechStepId: await techStepId("simmer"),
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.INVALID_CORRECTION_SPAN);
});
it("rejects an unknown correctedTechStepId with 404 TECH_STEP_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: 0, end: 5, correctedTechStepId: 999_999 });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND);
});
it("rejects a step that exists but isn't visible to the viewer with 404 RECIPE_NOT_FOUND", async () => {
const { profileId: authorId } = await signup();
const { agent: otherAgent } = await signup();
const recipe = await prisma.recipe.create({
data: {
name: "Secrète",
authorId,
portions: 4,
steps: { create: [{ description: "Faire mijoter la sauce.", order: 0 }] },
},
include: { steps: true },
});
const step = recipe.steps[0];
if (!step) throw new Error("expected the fixture recipe to have one step");
const res = await otherAgent
.post(`/recipes/${recipe.id}/steps/${step.id}/corrections`)
.send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
it("rejects a stepId that belongs to a different recipe than the URL's :id with 404 STEP_NOT_FOUND", async () => {
const { agent, profileId } = await signup();
const { recipeId: otherRecipeId } = await createPublicRecipeWithStep(profileId);
const { stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent
.post(`/recipes/${otherRecipeId}/steps/${stepId}/corrections`)
.send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.STEP_NOT_FOUND);
});
});
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();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const boilId = await techStepId("boil");
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
const res = await agent.get(`/recipes/${recipeId}/steps/${stepId}/corrections`);
expect(res.status).to.equal(200);
expect(res.body).to.have.length(2);
expect(res.body[0].correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
expect(res.body[1].correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
});
it("returns an empty list when nothing has been submitted yet", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent.get(`/recipes/${recipeId}/steps/${stepId}/corrections`);
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
});
});

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

@ -25,33 +25,6 @@ describe("registerAllRecipeSources", () => {
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("theMealDb");
});
it("registers Marmiton into the shared registry", () => {
registerAllRecipeSources();
const marmiton = getRecipeSource("marmiton");
expect(marmiton).to.not.be.undefined;
expect(marmiton?.name).to.equal("Marmiton");
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("marmiton");
});
it("registers 750g into the shared registry", () => {
registerAllRecipeSources();
const sevenFiftyG = getRecipeSource("750g");
expect(sevenFiftyG).to.not.be.undefined;
expect(sevenFiftyG?.name).to.equal("750g");
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("750g");
});
it("registers Manger Bouger into the shared registry", () => {
registerAllRecipeSources();
const mangerBouger = getRecipeSource("mangerBouger");
expect(mangerBouger).to.not.be.undefined;
expect(mangerBouger?.name).to.equal("Manger Bouger");
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("mangerBouger");
});
it("does not register the generic JSON-LD adapter — it's not a household-toggleable source in its own right", () => {
registerAllRecipeSources();

View file

@ -120,55 +120,6 @@ function buildDuplicateIngredientAdapter(key = "duplicateFakeSource"): RecipeSou
};
}
/**
* A minimal French-content fake adapter same shape as {@link buildFakeAdapter},
* `locale: "fr"` instead of `"en"`. Exercises `previewSourceItem` actually
* resolving ingredients for a non-English source through the real HTTP
* endpoint/catalog: `loadIngredientCatalog`/`loadUnitCatalog` used to be
* called only for `locale === "en"`, silently leaving every ingredient
* unresolved for a French source like Marmiton/750g/Manger Bouger the
* regression this test guards against.
*/
function buildFrenchFakeAdapter(key = "fakeFrSource"): RecipeSourceAdapter<{ externalId: string }> {
return {
key,
name: "Fake French Source",
official: true,
iconUrl: null,
locale: "fr",
async list(_params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
return {
items: [
{ externalId: "1", title: "Soupe à l'oignon", picture: null, url: "https://fake.test/1" },
],
nextCursor: null,
};
},
async fetchDetail(externalId: string): Promise<{ externalId: string }> {
return { externalId };
},
parse(raw: { externalId: string }): ParsedRecipe {
return {
name: `Recette factice ${raw.externalId}`,
description: null,
picture: null,
portions: 4,
sourceUrl: `https://fake.test/${raw.externalId}`,
ingredients: [
{ rawText: "3 carottes", quantity: null, unit: null, name: "carottes" },
{
rawText: "un ingrédient mystère",
quantity: null,
unit: null,
name: "ingrédient mystère",
},
],
steps: [{ description: "Faire mijoter à feu doux", picture: null }],
};
},
};
}
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as `recipe.test.ts`'s own helper. */
async function ingredientId(key: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
@ -354,37 +305,6 @@ describe("Sources", () => {
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
it("translates a French-locale source's item too, resolving ingredients against the French catalog (previously only 'en' sources ever got matched)", async () => {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFrenchFakeAdapter());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeFrSource" } });
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
const carrot = await prisma.ingredient.findFirstOrThrow({ where: { key: "carrot" } });
const piece = await prisma.unit.findFirstOrThrow({ where: { key: "piece" } });
const simmer = await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } });
const res = await agent.get("/sources/fakeFrSource/preview/1");
expect(res.status).to.equal(200);
const [resolved, unresolved] = res.body.ingredients;
expect(resolved.rawText).to.equal("3 carottes");
expect(resolved.ingredient).to.deep.include({ id: carrot.id, key: "carrot" });
// No explicit unit word in "3 carottes" — falls back to the generic
// "piece" unit (see translateRecipeIngredients' own doc comment on
// issue #53), same as the English fake adapter's "1 onion" would.
expect(resolved.unit).to.deep.include({ id: piece.id, key: "piece" });
expect(resolved.quantity).to.equal(3);
expect(unresolved.rawText).to.equal("un ingrédient mystère");
expect(unresolved.ingredient).to.equal(null);
expect(res.body.steps).to.have.length(1);
expect(res.body.steps[0].techSteps[0].techStep).to.deep.equal({
id: simmer.id,
key: "simmer",
});
});
it("merges two lines that resolve to the same ingredient, summing their quantity (issue #53 follow-up)", async () => {
const { agent } = await signupWithHouse();
registerRecipeSource(buildDuplicateIngredientAdapter());

View file

@ -1,277 +0,0 @@
import { useState } from "react";
import "../../src/i18n/i18n";
import { TechStepCorrectionPopover } from "../../src/features/recipes/steps/TechStepCorrectionPopover";
// Mounts the popover in isolation (no StepDescription/selection plumbing
// around it) — same "generic component test" posture as CheckboxOption.cy.tsx,
// but this one needs `../../src/i18n/i18n` imported for its side effect
// (initializes the default i18next instance `useTranslation` falls back to
// with no `<I18nextProvider>` in the tree — see that module's own doc
// comment) since, unlike Checkbox/Radio, this component calls
// `useTranslation()`.
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<{
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 (
<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 }} />
<TechStepCorrectionPopover
recipeId={2}
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}
/>
</div>
);
}
describe("TechStepCorrectionPopover", () => {
beforeEach(() => {
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 />);
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");
});
it("offers a 'no technique here' option, and marks the current pick, only when correcting an existing match", () => {
cy.mount(<Harness 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} />);
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", () => {
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");
const onSubmitted = cy.stub().as("onSubmitted");
cy.mount(<Harness onSubmitted={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.wait("@submitCorrection").its("request.body").should("deep.equal", {
start: 0,
end: 5,
previousTechStepId: null,
correctedTechStepId: simmer.id,
});
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} />);
cy.wait("@getTechSteps");
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Cuire",
).click();
cy.contains("button", "Valider").click();
cy.wait("@submitCorrection");
cy.get(".field-error").should("be.visible");
cy.get("@onClose").should("not.have.been.called");
});
it("calls onClose on an outside click", () => {
const onClose = cy.stub().as("onClose");
cy.mount(<Harness onClose={onClose} />);
cy.wait("@getTechSteps");
cy.get('[data-testid="outside-popover"]').click();
cy.get("@onClose").should("have.been.calledOnce");
});
});

View file

@ -6,20 +6,18 @@ import { splitDescriptionByTechSteps } from "../../src/features/recipes/steps/hi
// `expect`, not for rendering. `.cy.tsx` (not `.cy.ts`) only because that's
// what `cypress.config.ts`'s component `specPattern` looks for.
/** Builds a `StepTechStepView` — `context` omitted entirely (not just undefined) when absent, matching what the API actually sends for an older, not-yet-recomputed match (see `StepTechStepView`'s own doc comment). `source` defaults to `"auto"`, the common case every test not specifically about the manual/auto distinction uses. */
/** Builds a `StepTechStepView` — `context` omitted entirely (not just undefined) when absent, matching what the API actually sends for an older, not-yet-recomputed match (see `StepTechStepView`'s own doc comment). */
function techStep(
key: string,
id: number,
start: number,
end: number,
context?: { start: number; end: number },
source: StepTechStepView["source"] = "auto",
): StepTechStepView {
return {
techStep: { id, key },
start,
end,
source,
...(context ? { contextStart: context.start, contextEnd: context.end } : {}),
};
}
@ -27,7 +25,7 @@ function techStep(
describe("splitDescriptionByTechSteps", () => {
it("returns the whole description as one plain segment when there are no matches", () => {
expect(splitDescriptionByTechSteps("Servir immédiatement", [])).to.deep.equal([
{ text: "Servir immédiatement", techStep: null, isKeyword: false, source: null },
{ text: "Servir immédiatement", techStep: null, isKeyword: false },
]);
});
@ -39,25 +37,25 @@ describe("splitDescriptionByTechSteps", () => {
techStep("simmer", 1, 6, 13),
]);
expect(result).to.deep.equal([
{ text: "Faire ", techStep: null, isKeyword: false, source: null },
{ text: "mijoter", techStep: { id: 1, key: "simmer" }, isKeyword: true, source: "auto" },
{ text: " à feu doux", techStep: null, isKeyword: false, source: null },
{ text: "Faire ", techStep: null, isKeyword: false },
{ text: "mijoter", techStep: { id: 1, key: "simmer" }, isKeyword: true },
{ text: " à feu doux", techStep: null, isKeyword: false },
]);
});
it("handles a match at the very start, with nothing before it", () => {
const result = splitDescriptionByTechSteps("Hacher les oignons", [techStep("chop", 2, 0, 6)]);
expect(result).to.deep.equal([
{ text: "Hacher", techStep: { id: 2, key: "chop" }, isKeyword: true, source: "auto" },
{ text: " les oignons", techStep: null, isKeyword: false, source: null },
{ text: "Hacher", techStep: { id: 2, key: "chop" }, isKeyword: true },
{ text: " les oignons", techStep: null, isKeyword: false },
]);
});
it("handles a match at the very end, with nothing after it", () => {
const result = splitDescriptionByTechSteps("Faire cuire", [techStep("cook", 3, 6, 11)]);
expect(result).to.deep.equal([
{ text: "Faire ", techStep: null, isKeyword: false, source: null },
{ text: "cuire", techStep: { id: 3, key: "cook" }, isKeyword: true, source: "auto" },
{ text: "Faire ", techStep: null, isKeyword: false },
{ text: "cuire", techStep: { id: 3, key: "cook" }, isKeyword: true },
]);
});
@ -73,7 +71,6 @@ describe("splitDescriptionByTechSteps", () => {
text: "Préchauffer",
techStep: { id: 4, key: "preheat" },
isKeyword: true,
source: "auto",
});
});
@ -91,23 +88,17 @@ describe("splitDescriptionByTechSteps", () => {
it("drops a match whose end is past the end of the description", () => {
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 0, 999)]);
expect(result).to.deep.equal([
{ text: "Cuire", techStep: null, isKeyword: false, source: null },
]);
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]);
});
it("drops a match with a negative start", () => {
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, -1, 5)]);
expect(result).to.deep.equal([
{ text: "Cuire", techStep: null, isKeyword: false, source: null },
]);
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]);
});
it("drops a match whose start isn't before its end", () => {
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 3, 3)]);
expect(result).to.deep.equal([
{ text: "Cuire", techStep: null, isKeyword: false, source: null },
]);
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]);
});
it("drops a later match that overlaps one already accepted", () => {
@ -119,7 +110,7 @@ describe("splitDescriptionByTechSteps", () => {
techStep("cook", 2, 0, 5),
]);
expect(result).to.deep.equal([
{ text: "Cuire au four", techStep: { id: 3, key: "bake" }, isKeyword: true, source: "auto" },
{ text: "Cuire au four", techStep: { id: 3, key: "bake" }, isKeyword: true },
]);
});
@ -127,42 +118,6 @@ describe("splitDescriptionByTechSteps", () => {
expect(splitDescriptionByTechSteps("", [])).to.deep.equal([]);
});
it("carries a manual correction's source through its segments, distinct from an auto match", () => {
const text = "Faire mijoter le riz, puis dresser dans les assiettes";
const result = splitDescriptionByTechSteps(text, [
techStep("simmer", 1, 6, 13),
techStep("plate", 2, 28, 35, undefined, "manual"),
]);
const keywordSegments = result.filter((s) => s.isKeyword);
expect(keywordSegments.map((s) => ({ key: s.techStep?.key, source: s.source }))).to.deep.equal([
{ key: "simmer", source: "auto" },
{ key: "plate", source: "manual" },
]);
});
it("never lets one match's wider context swallow another match's own keyword span", () => {
// The motivating real bug (found via live testing, not invented for
// this test): "simmer" is the only NER candidate `splitIntoClauses`
// found, so its context spans the *entire* description — before this
// was fixed, that wide context advanced `cursor` past 39, silently
// dropping "setAside"'s own keyword span (a manual correction on
// "materiel", a word with no relation to "simmer" at all) instead of
// rendering it.
const text = "Faire mijoter la sauce, puis ranger le materiel.";
const result = splitDescriptionByTechSteps(text, [
techStep("simmer", 1, 6, 13, { start: 0, end: 48 }),
techStep("setAside", 2, 39, 47, undefined, "manual"),
]);
const keywordSegments = result.filter((s) => s.isKeyword);
expect(
keywordSegments.map((s) => ({ key: s.techStep?.key, text: s.text, source: s.source })),
).to.deep.equal([
{ key: "simmer", text: "mijoter", source: "auto" },
{ key: "setAside", text: "materiel", source: "manual" },
]);
expect(result.map((s) => s.text).join("")).to.equal(text);
});
describe("with a context span wider than the keyword", () => {
it("splits into context-before / keyword / context-after around a keyword in the middle of its clause", () => {
// The motivating example: "Dans une poêle chaude, faire chauffer une
@ -173,23 +128,12 @@ describe("splitDescriptionByTechSteps", () => {
techStep("preheat", 4, 9, 21, { start: 0, end: 21 }),
]);
expect(result).to.deep.equal([
{
text: "Dans une ",
techStep: { id: 4, key: "preheat" },
isKeyword: false,
source: "auto",
},
{
text: "poêle chaude",
techStep: { id: 4, key: "preheat" },
isKeyword: true,
source: "auto",
},
{ text: "Dans une ", techStep: { id: 4, key: "preheat" }, isKeyword: false },
{ text: "poêle chaude", techStep: { id: 4, key: "preheat" }, isKeyword: true },
{
text: ", faire chauffer une noix de beurre",
techStep: null,
isKeyword: false,
source: null,
},
]);
});
@ -199,13 +143,8 @@ describe("splitDescriptionByTechSteps", () => {
techStep("preheat", 4, 0, 11, { start: 0, end: 19 }),
]);
expect(result).to.deep.equal([
{
text: "préchauffer",
techStep: { id: 4, key: "preheat" },
isKeyword: true,
source: "auto",
},
{ text: " le four", techStep: { id: 4, key: "preheat" }, isKeyword: false, source: "auto" },
{ text: "préchauffer", techStep: { id: 4, key: "preheat" }, isKeyword: true },
{ text: " le four", techStep: { id: 4, key: "preheat" }, isKeyword: false },
]);
});
@ -214,19 +153,9 @@ describe("splitDescriptionByTechSteps", () => {
techStep("preheat", 4, 17, 28, { start: 7, end: 28 }),
]);
expect(result).to.deep.equal([
{ text: "mettre ", techStep: null, isKeyword: false, source: null },
{
text: "le four à ",
techStep: { id: 4, key: "preheat" },
isKeyword: false,
source: "auto",
},
{
text: "préchauffer",
techStep: { id: 4, key: "preheat" },
isKeyword: true,
source: "auto",
},
{ text: "mettre ", techStep: null, isKeyword: false },
{ text: "le four à ", techStep: { id: 4, key: "preheat" }, isKeyword: false },
{ text: "préchauffer", techStep: { id: 4, key: "preheat" }, isKeyword: true },
]);
});
@ -242,9 +171,7 @@ describe("splitDescriptionByTechSteps", () => {
// contextEnd (5) is before the keyword's own end (13) — malformed.
techStep("bake", 3, 0, 13, { start: 0, end: 5 }),
]);
expect(result).to.deep.equal([
{ text: "Cuire au four", techStep: null, isKeyword: false, source: null },
]);
expect(result).to.deep.equal([{ text: "Cuire au four", techStep: null, isKeyword: false }]);
});
});
});

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

@ -45,29 +45,6 @@ Feature: Browsing external recipe sources
And the recipe detail panel heading should be "Fish Pie"
And I should see the highlighted technique "Cuire"
Scenario: Loads further pages automatically, with no "load more" button
Given the recipe catalog contains nothing
And the sources reference list has options
And the household has enabled TheMealDB
And browsing TheMealDB returns two pages of items
When I visit "/recettes"
And I click the button "TheMealDB"
Then I should see the source item "Chicken Handi"
And I should see the source item "Beef Wellington"
And I should not see "Voir plus"
Scenario: Offers a retry when loading the next page fails
Given the recipe catalog contains nothing
And the sources reference list has options
And the household has enabled TheMealDB
And browsing TheMealDB's next page fails once, then succeeds
When I visit "/recettes"
And I click the button "TheMealDB"
Then I should see the source item "Chicken Handi"
And I should see a message to retry loading more
When I click the button "Réessayer"
Then I should see the source item "Beef Wellington"
Scenario: Deep-links straight to a not-yet-imported item's own page, with no import affordance at all
Given the recipe catalog contains nothing
And the sources reference list has options

View file

@ -83,81 +83,6 @@ Given("browsing TheMealDB returns some items", () => {
});
});
/**
* A second, distinct item from `Given("browsing TheMealDB returns some
* items")`'s page-1 pair — used by the infinite-scroll/retry scenarios
* below, which need to tell "the item that only shows up once the *next*
* page has loaded" apart from what's already visible on page 1.
*/
const BEEF_WELLINGTON_ITEM = {
externalId: "77123",
title: "Beef Wellington",
picture: null,
url: "https://www.themealdb.com/meal/77123",
alreadyImported: false,
recipeId: null,
};
const CHICKEN_HANDI_AND_FISH_PIE_PAGE_1 = {
items: [
{
externalId: "52795",
title: "Chicken Handi",
picture: null,
url: "https://www.themealdb.com/meal/52795",
alreadyImported: true,
recipeId: 2,
},
{
externalId: "9999",
title: "Fish Pie",
picture: null,
url: "https://www.themealdb.com/meal/9999",
alreadyImported: false,
recipeId: null,
},
],
nextCursor: "2",
};
Given("browsing TheMealDB returns two pages of items", () => {
cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => {
const isNextPage = req.url.includes("cursor=");
req.reply({
statusCode: 200,
body: isNextPage
? { items: [BEEF_WELLINGTON_ITEM], nextCursor: null }
: CHICKEN_HANDI_AND_FISH_PIE_PAGE_1,
});
});
});
// The panel prefetches the next page as soon as page 1 is on screen (before
// anyone's actually waited on it), so the *first* request for it is that
// prefetch — this is what actually fails "once", not a request triggered by
// a click. `handleLoadMore`'s own retry then makes a genuinely fresh
// request (see its own doc comment on why a failed prefetch gets cleared),
// which is the one that succeeds here.
Given("browsing TheMealDB's next page fails once, then succeeds", () => {
let nextPageAttempts = 0;
cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => {
if (!req.url.includes("cursor=")) {
req.reply({ statusCode: 200, body: CHICKEN_HANDI_AND_FISH_PIE_PAGE_1 });
return;
}
nextPageAttempts += 1;
if (nextPageAttempts === 1) {
req.reply({ statusCode: 500, body: {} });
} else {
req.reply({ statusCode: 200, body: { items: [BEEF_WELLINGTON_ITEM], nextCursor: null } });
}
});
});
Then("I should see a message to retry loading more", () => {
cy.contains(".recipes-page__status--error", "Réessayer").should("be.visible");
});
Given("previewing TheMealDB item {string} is available", (externalId: string) => {
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
statusCode: 200,

View file

@ -27,24 +27,6 @@ Feature: Managing a recipe from the catalog
When I focus the highlighted technique "Cuire"
Then the tooltip should show "Cuire"
Scenario: Corrects a detected technique from its highlight, visible immediately as a manual match
Given the recipe catalog contains "Omelette"
And recipe 2's detail is available
And the tech steps reference list has options
And correcting step 2's "Cuire" match will succeed
When I visit "/recettes/2"
And I click the highlighted technique "Cuire"
Then I should see the technique correction options
When I choose "Mijoter" as the correct technique
Then the correction request should have been made
# The highlighted *word* stays "Cuire" (a relabel changes which
# technique a span means, not the literal text at that span, still
# "Cuire" in the source description) — now styled as a manual
# correction, with its tooltip naming the newly-assigned technique.
And the highlighted technique "Cuire" should be marked as a manual correction
When I focus the highlighted technique "Cuire"
Then the tooltip should show "Mijoter (correction manuelle)"
Scenario: Deletes a recipe after a two-step confirmation, then clears the selection
Given the recipe catalog contains "Omelette"
And recipe 2's detail is available

View file

@ -43,7 +43,7 @@ const omeletteDetail = {
// "Cuire" -> the `cook` technique, matching real reference-seed-data.ts
// (`\bcui(re|sez|sant|sson)\b`) — "poêle" itself matches nothing
// (that's `panFry`'s "sauter", a different word).
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5, source: "auto" }],
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }],
},
],
};
@ -68,84 +68,6 @@ Given("deleting recipe 2 will succeed", () => {
cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe");
});
// Step 2 is `omeletteDetail`'s "Cuire à la poêle." step, whose only
// existing match is `cook` (id 1) — see that fixture above. The response
// mirrors `SubmitTechStepCorrectionResult` (packages/shared): the audit
// record (reassigning the match to `simmer`, id 3, "Mijoter" — see `the
// tech steps reference list has options`, reference-data.steps.ts) plus
// the step's fresh `techSteps`, now showing that same reassignment as a
// `"manual"`-sourced entry — the API applies a correction immediately, it
// doesn't just record it (see `StepTechStepView.source`'s own doc comment).
Given('correcting step 2\'s "Cuire" match will succeed', () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201,
body: {
correction: {
id: 1,
start: 0,
end: 5,
previousTechStep: { id: 1, key: "cook" },
correctedTechStep: { id: 3, key: "simmer" },
createdAt: new Date().toISOString(),
},
techSteps: [
{
techStep: { id: 3, key: "simmer" },
start: 0,
end: 5,
source: "manual",
ingredients: [],
utensils: [],
},
],
},
}).as("correction");
});
When("I click the highlighted technique {string}", (text: string) => {
cy.contains(".step-tech-step", text).click();
});
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();
});
Then(
"the highlighted technique {string} should be marked as a manual correction",
(text: string) => {
cy.contains(".step-tech-step", text).should("have.class", "step-tech-step--manual");
},
);
Then("the correction request should have been made", () => {
// Asserts the actual span, not just that *a* request fired — a real bug
// (StepDescription.tsx's click handler reading a shared, still-mutating
// `offset` variable by reference instead of a value captured at render
// time) once sent `end` all the way to the end of the description
// instead of "Cuire"'s own tight [0, 5) span, and a request-fired-only
// assertion here didn't catch it — found only via manual testing.
cy.wait("@correction")
.its("request.body")
.should("deep.include", { start: 0, end: 5, previousTechStepId: 1 });
});
Then("the recipe {string} should not be visible in the table", (name: string) => {
cy.contains(".recipe-table__name", name).should("not.exist");
});

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

@ -40,21 +40,6 @@ Given("the sources reference list is empty", () => {
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
});
// `id`/`key` pairs mirror `recipes.ts`'s `omeletteDetail` fixture (`cook`,
// id 1, is the step's existing match) plus a second option
// (`simmer`/"Mijoter") for `recipes.feature`'s correction scenario to
// re-assign to — TechStepCorrectionPopover's own picker needs at least two
// choices for that scenario to be a meaningful correction, not a no-op.
Given("the tech steps reference list has options", () => {
cy.intercept("GET", "**/reference/tech-steps", {
statusCode: 200,
body: [
{ id: 1, key: "cook" },
{ id: 3, key: "simmer" },
],
});
});
// The real first entry (`theMealDb`) mirrors what's actually seeded
// (`reference-seed-data.ts`'s `registerAllRecipeSources`/
// `syncRecipeSources`); the second is illustrative only — a future

View file

@ -17,17 +17,11 @@ import {
type RecipeTab,
type RecipeView,
type SafeUserProfile,
type ShoppingListView,
type SignupInput,
type SourceView,
type StepTechStepCorrectionView,
type SubmitTechStepCorrectionInput,
type SubmitTechStepCorrectionResult,
type TechStepView,
type ThemePreference,
type UnitView,
type UpdateRecipeInput,
type UtensilView,
} from "@batch-cooking/shared";
/**
@ -165,18 +159,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");
@ -197,16 +179,6 @@ export class ApiClient {
return this._request("/reference/units");
}
/** Reference list of detected cooking techniques — static, non-administrable (`TechStepCorrectionPopover`'s technique picker). Public — no session required. */
public getTechSteps(): Promise<TechStepView[]> {
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");
@ -295,26 +267,6 @@ export class ApiClient {
return this._request(`/recipes/${id}/favorite`, { method: "DELETE" });
}
/** Submits a correction to one of `stepId`'s detected techniques — see `SubmitTechStepCorrectionInput`'s doc comment (`packages/shared`) for what `previousTechStepId`/`correctedTechStepId` each mean. Open to any viewer who can see the recipe, not just its author. The response's `techSteps` is the step's fresh, immediately up-to-date technique sequence — see `SubmitTechStepCorrectionResult`'s doc comment. */
public submitTechStepCorrection(
recipeId: number,
stepId: number,
input: SubmitTechStepCorrectionInput,
): Promise<SubmitTechStepCorrectionResult> {
return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** Every correction submitted so far for `stepId`, most recent first. */
public getTechStepCorrections(
recipeId: number,
stepId: number,
): Promise<StepTechStepCorrectionView[]> {
return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`);
}
/** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */
public getCurrentHouse(): Promise<HouseView | null> {
return this._request("/house/current");

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

@ -205,34 +205,11 @@ export function RecipeDetailPanel({
<section className="recipe-detail-panel__section">
<h3>{t("recipes.stepsTitle")}</h3>
{/* Discoverability hint for the highlight/correction feature below
nothing about the steps list itself otherwise signals that a
highlighted technique or a plain-text selection is interactive.
Tied to `showActions`, same reasoning as `StepDescription`'s own
`editable` prop right below. */}
{showActions && (
<p className="recipe-detail-panel__tech-step-hint">
{t("recipes.techStepCorrection.discoverabilityHint")}
</p>
)}
<ol className="recipe-detail-panel__steps">
{recipe.steps.map((step) => (
<li key={step.id}>
{step.picture && <img src={step.picture} alt="" />}
{/* Tied to `showActions` (not unconditionally on): that flag
already distinguishes a full recipe view from a lightweight
preview (`RecipePickerDialog`'s browsing step, `showActions={false}`)
offering technique corrections in a quick "pick a recipe
for planning" preview would be more distracting than
useful there, even though the API itself allows it for any
viewer who can see the recipe. */}
<StepDescription
description={step.description}
techSteps={step.techSteps}
editable={showActions}
recipeId={recipe.id}
stepId={step.id}
/>
<StepDescription description={step.description} techSteps={step.techSteps} />
</li>
))}
</ol>

View file

@ -372,75 +372,20 @@
gap: var(--space-sm);
}
// Browsing a source scrolls infinitely (SourceItemTable's own sentinel row
// triggers RecipeSourcesPanel's handleLoadMore) — this is only the inline
// retry action shown alongside recipes.sources.loadMoreError when a page
// fetch actually fails, not a persistent "load more" control.
.source-items-retry {
padding: 0;
font-family: inherit;
font-size: inherit;
font-weight: 600;
.source-items-load-more {
flex-shrink: 0;
align-self: center;
padding: 0.4rem var(--space-lg);
font-family: var(--font-body);
font-size: var(--font-size-sm);
color: var(--color-primary);
background: none;
border: none;
text-decoration: underline;
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
cursor: pointer;
}
// Pulsing placeholder rows `SourceItemTable` appends below the real items
// while `RecipeSourcesPanel`'s "load more" fetch is in flight — with that
// panel prefetching the next page ahead of time (as soon as the sentinel
// row scrolls near view), this is usually a very brief flash rather than
// an actual wait, but it keeps the list
// filling in instead of looking like nothing happened either way.
@keyframes source-item-skeleton-pulse {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 0.9;
}
}
// Invisible marker row `SourceItemTable`'s `IntersectionObserver` watches
// to trigger infinite scroll no padding/border of its own, unlike a real
// row, so it doesn't show up as a stray empty stripe at the bottom of the
// list.
.source-item-table__sentinel td {
padding: 0;
border: none;
}
.source-item-table__row--skeleton {
cursor: default;
pointer-events: none;
&:hover {
background: none;
}
}
.source-item-table__photo--skeleton,
.source-item-table__skeleton-bar {
background: var(--color-border);
animation: source-item-skeleton-pulse 1.4s ease-in-out infinite;
}
.source-item-table__skeleton-bar {
display: block;
width: 60%;
height: 0.9rem;
border-radius: var(--radius-pill);
}
@media (prefers-reduced-motion: reduce) {
.source-item-table__photo--skeleton,
.source-item-table__skeleton-bar {
animation: none;
opacity: 0.6;
border-color: var(--color-primary);
}
}
@ -682,273 +627,12 @@
}
}
// A `"manual"`-sourced match (a viewer's correction, applied immediately —
// see `StepTechStepView.source`) same shape as `.step-tech-step`, but in
// `--color-tag` (Turmeric) instead of `--color-primary` (Basil), so the two
// origins are distinguishable at a glance, not just via the tooltip text.
.step-tech-step--manual {
background: color-mix(in srgb, var(--color-tag) 18%, transparent);
text-decoration-color: var(--color-tag);
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--color-tag) 28%, transparent);
}
}
// `.step-tech-step-context` (the wider clause a `.step-tech-step` keyword
// was found in) used to be highlighted here too, more subtly turned back
// off (see `StepDescription.tsx`'s doc comment): the backend still
// computes and persists `contextStart`/`contextEnd`, this file just no
// longer gives that class any styling to render with.
// Discoverability hint above the steps list (RecipeDetailPanel.tsx)
// muted so it reads as a small aside, not competing with the steps
// themselves for attention.
.recipe-detail-panel__tech-step-hint {
margin: 0 0 var(--space-sm);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// --- Tech-step correction (StepDescription.tsx editable mode) ---------------
// Deliberately *not* `position: absolute` (unlike `.calendar-popover`) see
// `TechStepCorrectionPopover.tsx`'s doc comment for why this renders inline
// in the document flow right below the step's own description instead of
// floating anchored at the selection.
.tech-step-correction-popover {
margin-top: var(--space-xs);
padding: var(--space-md);
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
&__selection {
font-weight: 600;
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 {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-sm);
}
&__remove {
padding: 0.2rem 0.5rem;
font-size: var(--font-size-sm);
color: var(--color-error);
background: none;
border: 1px solid var(--color-error);
border-radius: var(--radius-pill);
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&__cancel {
background: none;
border: none;
color: var(--color-text-muted);
text-decoration: underline;
cursor: pointer;
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) -----------------------------
.favorite-star-button {
position: absolute;

View file

@ -1,5 +1,5 @@
import type { BrowsableSourceItemView, RecipeView } from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiClient } from "../../../api/client";
import { RecipeDetailPanel, type RecipeDetailState } from "../RecipeDetailPanel";
@ -9,15 +9,6 @@ import "../recipes.scss";
/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */
const SEARCH_DEBOUNCE_MS = 300;
/** How many pulsing skeleton rows `handleLoadMore` shows while its fetch is in flight — see `loadMoreStatus`. Not tied to any source's real page size (that varies per source, and isn't known client-side); just enough to visibly fill the gap below the list without over-promising. */
const LOAD_MORE_PLACEHOLDER_COUNT = 4;
/** One page of `sourceKey`'s browsable catalog, as returned by `apiClient.browseSource`. */
interface BrowsePage {
items: BrowsableSourceItemView[];
nextCursor: string | null;
}
/** One item's identity within a source's browsable catalog — `sourceKey` + `externalId` together, since `externalId` alone is only unique per source. */
export interface SourceItemSelection {
sourceKey: string;
@ -85,30 +76,6 @@ export function RecipeSourcesPanel({
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
const [loadMoreStatus, setLoadMoreStatus] = useState<"idle" | "loading" | "error">("idle");
// The *next* page, fetched ahead of time as soon as the current one is on
// screen (see the effect below) — a ref, not state, since it's an
// implementation detail `handleLoadMore` consumes, never itself rendered.
// Keyed on exactly what makes a prefetch valid to reuse (source/query/
// cursor all matching) rather than just "is something in flight", so a
// stale prefetch from before a search/source change is never mistaken for
// the page that's actually needed next.
const nextPagePrefetchRef = useRef<{
sourceKey: string;
query: string;
cursor: string;
promise: Promise<BrowsePage>;
} | null>(null);
// Re-entrancy guard for `handleLoadMore` — infinite scroll (unlike a
// button `onClick`) can call it again before the previous call has
// settled (e.g. the sentinel row is still intersecting when the observer
// re-evaluates after a layout shift). A ref, not `loadMoreStatus`: that
// state only exists to drive what's rendered and is read from React's
// closure at call time, which would still read the *previous* render's
// (stale) value inside a handler fired synchronously off a fresh
// browser event — this needs to be checked/set immediately and
// synchronously, which only a ref does correctly here.
const isLoadingMoreRef = useRef(false);
const [selectedExternalId, setSelectedExternalId] = useState<string | null>(
initialSelection?.externalId ?? null,
);
@ -160,11 +127,6 @@ export function RecipeSourcesPanel({
useEffect(() => {
let cancelled = false;
setBrowseState({ status: "loading" });
// A fresh search/source is a fresh list — any in-flight "load more" or
// stale prefetch from the *previous* one no longer applies to anything.
setLoadMoreStatus("idle");
nextPagePrefetchRef.current = null;
isLoadingMoreRef.current = false;
apiClient
.browseSource(sourceKey, { query: debouncedSearch.trim() || undefined })
@ -180,78 +142,21 @@ export function RecipeSourcesPanel({
};
}, [sourceKey, debouncedSearch]);
// Prefetches the page after the one currently on screen, so scrolling
// near the bottom (SourceItemTable's sentinel row, which calls
// handleLoadMore) usually just swaps in data that's already arrived
// instead of starting a fresh round-trip right when someone's waiting on
// it — `handleLoadMore` below reuses this when it matches. Re-runs on every
// `browseState` change, so a load-more that appends a new page and a new
// `nextCursor` immediately kicks off prefetching the page *after* that
// one too, keeping the panel permanently one page ahead of what's shown.
useEffect(() => {
if (browseState.status !== "loaded" || !browseState.nextCursor) return;
const cursor = browseState.nextCursor;
const query = debouncedSearch.trim();
const already = nextPagePrefetchRef.current;
if (
already &&
already.sourceKey === sourceKey &&
already.query === query &&
already.cursor === cursor
) {
return; // already prefetching/prefetched exactly this page
}
const promise = apiClient.browseSource(sourceKey, { query: query || undefined, cursor });
nextPagePrefetchRef.current = { sourceKey, query, cursor, promise };
// A failed prefetch is swallowed here on purpose — nobody's actually
// waiting on it yet. If `handleLoadMore` later reuses this same promise
// it awaits/catches the rejection itself at that point; if it's never
// reused (the prefetch just goes stale), this `.catch()` only exists to
// keep the rejection from surfacing as an unhandled one.
promise.catch(() => {});
}, [browseState, sourceKey, debouncedSearch]);
function handleLoadMore() {
if (browseState.status !== "loaded" || !browseState.nextCursor) {
return;
}
if (isLoadingMoreRef.current) {
return; // already fetching this exact next page — see the ref's own doc comment
}
isLoadingMoreRef.current = true;
const cursor = browseState.nextCursor;
const query = debouncedSearch.trim();
setLoadMoreStatus("loading");
const prefetch = nextPagePrefetchRef.current;
const request =
prefetch &&
prefetch.sourceKey === sourceKey &&
prefetch.query === query &&
prefetch.cursor === cursor
? prefetch.promise
: apiClient.browseSource(sourceKey, { query: query || undefined, cursor });
request
apiClient
.browseSource(sourceKey, { query: debouncedSearch.trim() || undefined, cursor })
.then(({ items, nextCursor }) => {
nextPagePrefetchRef.current = null;
isLoadingMoreRef.current = false;
setBrowseState((prev) =>
prev.status === "loaded"
? { status: "loaded", items: [...prev.items, ...items], nextCursor }
: prev,
);
setLoadMoreStatus("idle");
})
.catch(() => {
// Also clears a failed *prefetch*, not just a failed manual retry —
// otherwise a rejected promise would sit in the ref forever, and
// "Réessayer" would just keep reusing (and re-rejecting on) that
// same dead promise instead of ever making a fresh request.
nextPagePrefetchRef.current = null;
isLoadingMoreRef.current = false;
setLoadMoreStatus("error");
});
.catch(() => setBrowseState({ status: "error" }));
}
function handleSelectItem(item: BrowsableSourceItemView) {
@ -312,17 +217,11 @@ export function RecipeSourcesPanel({
items={browseState.items}
selectedExternalId={selectedExternalId}
onSelect={handleSelectItem}
placeholderCount={loadMoreStatus === "loading" ? LOAD_MORE_PLACEHOLDER_COUNT : 0}
hasMore={browseState.nextCursor !== null}
onLoadMore={handleLoadMore}
/>
{loadMoreStatus === "error" && (
<p className="recipes-page__status recipes-page__status--error">
{t("recipes.sources.loadMoreError")}{" "}
<button type="button" className="source-items-retry" onClick={handleLoadMore}>
{t("recipes.sources.retry")}
</button>
</p>
{browseState.nextCursor && (
<button type="button" className="source-items-load-more" onClick={handleLoadMore}>
{t("recipes.sources.loadMore")}
</button>
)}
</div>
)}

View file

@ -1,5 +1,4 @@
import type { BrowsableSourceItemView } from "@batch-cooking/shared";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import "../recipes.scss";
@ -9,66 +8,20 @@ import "../recipes.scss";
* an "already imported" badge in place of allergen/regime columns (a
* source item has neither, it's not resolved against our catalogs until
* previewed).
*
* Infinite scroll, not a "voir plus" button: a zero-content sentinel row
* (`source-item-table__sentinel`) sits right after `items`, watched by an
* `IntersectionObserver` scoped to this table's own scrolling container
* (`.recipe-table-wrap`, not the page) scrolling it into view calls
* `onLoadMore`, the same callback a button's `onClick` would have. Kept
* entirely inside this component rather than exposed as a prop callback
* signature change on every call site: `RecipeSourcesPanel` doesn't need to
* know *how* "load more" gets triggered, only that it does.
*/
export function SourceItemTable({
items,
selectedExternalId,
onSelect,
placeholderCount = 0,
hasMore = false,
onLoadMore,
}: {
items: BrowsableSourceItemView[];
selectedExternalId: string | null;
onSelect: (item: BrowsableSourceItemView) => void;
/**
* Extra pulsing skeleton rows appended after `items` `RecipeSourcesPanel`
* sets this while a "load more" fetch is in flight, so the list fills in
* right away instead of looking like nothing happened. Purely decorative:
* never clickable/focusable, unlike a real row.
*/
placeholderCount?: number;
/** Whether a further page exists — renders the sentinel row (and therefore observes it) only when true; the observer would otherwise have nothing meaningful to trigger once the catalog is exhausted. */
hasMore?: boolean;
/** Called once when the sentinel row scrolls into view — `RecipeSourcesPanel`'s own re-entrancy guard (not this component) is what keeps a still-visible sentinel from firing this repeatedly while a fetch is already in flight. */
onLoadMore?: () => void;
}) {
const { t } = useTranslation();
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const sentinelRef = useRef<HTMLTableRowElement | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: `onLoadMore` is deliberately excluded — RecipeSourcesPanel passes a fresh function identity on every render, and re-subscribing the observer on every single render (rather than only when hasMore actually flips) would be wasteful busywork for no behavioral difference.
useEffect(() => {
const root = scrollContainerRef.current;
const sentinel = sentinelRef.current;
if (!hasMore || !onLoadMore || !root || !sentinel) return;
// `rootMargin` starts loading the next page slightly before the
// sentinel actually reaches the visible edge — combined with
// RecipeSourcesPanel's own prefetch-ahead-of-time, the goal is that a
// page finishes arriving before anyone actually scrolls far enough to
// need it, not just "as soon as" they do.
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) onLoadMore();
},
{ root, rootMargin: "200px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore]);
return (
<div className="recipe-table-wrap" ref={scrollContainerRef}>
<div className="recipe-table-wrap">
<table className="recipe-table">
<thead>
<tr>
@ -107,23 +60,6 @@ export function SourceItemTable({
</td>
</tr>
))}
{hasMore && (
<tr ref={sentinelRef} className="source-item-table__sentinel">
<td colSpan={3} />
</tr>
)}
{Array.from({ length: placeholderCount }, (_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: a fixed-length run of interchangeable, content-less placeholders — there's no stable identity to key on, and the count never reorders.
<tr key={`skeleton-${index}`} className="source-item-table__row--skeleton">
<td>
<span className="recipe-table__photo source-item-table__photo--skeleton" />
</td>
<td>
<span className="source-item-table__skeleton-bar" />
</td>
<td />
</tr>
))}
</tbody>
</table>
</div>

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

@ -1,10 +1,8 @@
import type { StepTechStepView, SubmitTechStepCorrectionResult } from "@batch-cooking/shared";
import { Fragment, useEffect, useRef, useState } from "react";
import type { StepTechStepView } from "@batch-cooking/shared";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "../../../components/ui/Tooltip";
import { splitDescriptionByTechSteps } from "./highlight-tech-steps";
import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
/**
* A recipe step's description, with every detected technique's exact
@ -25,222 +23,48 @@ import { type TextSelectionRange, useTextSelection } from "./use-text-selection"
*
* `techStep.key` resolves its tooltip label through `catalog.techSteps.<key>`
* i18n, the same pattern every other reference catalog (diets, units, )
* uses for its display text. A keyword's `source` (`"auto"` the
* classifier vs `"manual"` a viewer's correction, applied immediately)
* gets its own modifier class (`.step-tech-step--manual`), a different
* color, so the two are visually distinguishable at a glance rather than
* only via the tooltip text.
*
* `editable` (off by default) additionally lets the viewer select text or
* click an existing highlight to open a {@link TechStepCorrectionPopover}
* see `use-text-selection.ts` for how a browser selection is translated
* into an absolute `[start, end)` span. When `!editable`, every segment
* renders exactly as before (no extra wrapping elements, no `data-offset`,
* no click handlers) this mode is purely additive, not a rewrite of the
* read-only rendering.
*
* Maintains its own local copy of `techSteps` (seeded from the prop, then
* replaced with whatever `POST .../corrections` returns on a successful
* submit see `SubmitTechStepCorrectionResult`'s doc comment,
* `packages/shared`) so a correction's effect (a new/relabeled/removed
* highlight) appears immediately, without needing the parent to re-fetch
* the whole recipe. Resynced whenever the `techSteps` prop itself changes
* (e.g. the parent reloaded the recipe for an unrelated reason) so this
* never keeps showing stale local state past that.
* uses for its display text.
*/
export function StepDescription({
description,
techSteps,
editable = false,
recipeId,
stepId,
}: {
description: string;
techSteps: StepTechStepView[];
/** Requires `recipeId`/`stepId` when `true` — omit (or leave `false`) for a read-only view with nothing real to correct against yet (e.g. `RecipeDetailPanel`'s `"loaded-draft"` unsaved-preview branch). */
editable?: boolean;
recipeId?: number;
stepId?: number;
}) {
const { t } = useTranslation();
const [liveTechSteps, setLiveTechSteps] = useState(techSteps);
useEffect(() => setLiveTechSteps(techSteps), [techSteps]);
const segments = splitDescriptionByTechSteps(description, liveTechSteps);
const containerRef = useRef<HTMLParagraphElement>(null);
const { getSelectionRange } = useTextSelection(containerRef);
const [activeCorrection, setActiveCorrection] = useState<{
range: TextSelectionRange;
selectedText: string;
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),
previousTechStepId: null,
});
}
function handleSubmitted(result: SubmitTechStepCorrectionResult) {
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
// comment), so a running total is exact, no re-derivation needed.
let offset = 0;
const segments = splitDescriptionByTechSteps(description, techSteps);
return (
<>
<p ref={containerRef} onMouseUp={handleMouseUp}>
{segments.map((segment, index) => {
const start = offset;
offset += segment.text.length;
// Captured now, not read as `offset` later inside a click
// handler below — `offset` keeps mutating for every subsequent
// segment this same `.map()` pass renders, so a closure
// referencing it directly would see its *final* value (the end
// of the whole description) whenever it actually fires, long
// after render — found via a real correction submitted with
// `end` far past this segment's own text.
const end = offset;
// A segment's own text/techStep don't uniquely identify it (the
// same word can appear twice in one description) — index is the
// only thing that does, but this list is fully regenerated from
// `description`/`liveTechSteps` on every render (never reordered
// or spliced in place), so using it as part of the key is safe
// here.
const key = `${index}-${segment.text}`;
<p>
{segments.map((segment, index) => {
// A segment's own text/techStep don't uniquely identify it (the
// same word can appear twice in one description) — index is the
// only thing that does, but this list is fully regenerated from
// `description`/`techSteps` on every render (never reordered or
// spliced in place), so using it as part of the key is safe here.
const key = `${index}-${segment.text}`;
if (!segment.techStep) return <Fragment key={key}>{segment.text}</Fragment>;
if (!segment.techStep || !segment.isKeyword) {
// Context-only or plain run — rendered as plain text in
// read-only mode, same as before this component supported
// `editable` at all (see this component's doc comment for why
// the wider-clause highlight itself was turned back off).
// Editable mode still wraps it in a `data-offset` span so a
// selection starting/ending in plain text resolves correctly.
if (!editable) return <Fragment key={key}>{segment.text}</Fragment>;
return (
<span key={key} data-offset={start}>
{segment.text}
</span>
);
}
const techStep = segment.techStep;
const isManual = segment.source === "manual";
const tooltipLabel = isManual
? t("recipes.techStepCorrection.manualTooltip", {
technique: t(`catalog.techSteps.${techStep.key}`),
})
: t(`catalog.techSteps.${techStep.key}`);
return (
<Tooltip key={key} content={tooltipLabel}>
{/* A real <button>, not a <mark>, so it's natively focusable
(keyboard/screen-reader users can reach the tooltip) without
fighting the "non-interactive element" a11y lint a bare
tabIndex on <mark> would trip styled to read as inline
highlighted text, not as a button (see .step-tech-step). */}
<button
type="button"
className={isManual ? "step-tech-step step-tech-step--manual" : "step-tech-step"}
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
}
>
{segment.text}
</button>
</Tooltip>
);
})}
</p>
{editable && activeCorrection && recipeId !== undefined && stepId !== undefined && (
<TechStepCorrectionPopover
recipeId={recipeId}
stepId={stepId}
range={activeCorrection.range}
selectedText={activeCorrection.selectedText}
previousTechStepId={activeCorrection.previousTechStepId}
existingIngredients={activeStepTechStep?.ingredients ?? []}
existingUtensils={activeStepTechStep?.utensils ?? []}
resolvedMetadataSpan={resolvedMetadataSpan}
onRequestSpan={setPendingSpanRequest}
onClose={closeActiveCorrection}
onSubmitted={handleSubmitted}
/>
)}
</>
if (!segment.isKeyword) {
// Context-only run — rendered as plain text, same as a segment
// with no technique at all (see this component's doc comment for
// why the wider-clause highlight was turned back off).
return <Fragment key={key}>{segment.text}</Fragment>;
}
return (
<Tooltip key={key} content={t(`catalog.techSteps.${segment.techStep.key}`)}>
{/* A real <button>, not a <mark>, so it's natively focusable
(keyboard/screen-reader users can reach the tooltip) without
fighting the "non-interactive element" a11y lint a bare
tabIndex on <mark> would trip styled to read as inline
highlighted text, not as a button (see .step-tech-step). */}
<button type="button" className="step-tech-step">
{segment.text}
</button>
</Tooltip>
);
})}
</p>
);
}

View file

@ -1,522 +0,0 @@
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
* opened by `StepDescription`'s editable mode. Same `mousedown`-outside-
* close pattern as `PlanningPage`'s `CalendarPopover`, not `Dialog.tsx`'s
* native `<dialog>` this is a small, contextual pick-one-option surface,
* not a page-blocking modal.
*
* Rendered inline right below the step's own description block (see
* `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.
*
* **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*.
*/
export function TechStepCorrectionPopover({
recipeId,
stepId,
selectedText,
range,
previousTechStepId,
existingIngredients,
existingUtensils,
resolvedMetadataSpan,
onRequestSpan,
onClose,
onSubmitted,
}: {
recipeId: number;
stepId: number;
/** The selected span's own text — shown so the user confirms what they're tagging before picking a technique. */
selectedText: string;
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);
useEffect(() => {
let cancelled = false;
apiClient
.getTechSteps()
.then((list) => {
if (!cancelled) setTechSteps(list);
})
.catch(() => {
if (!cancelled) setTechSteps([]);
});
return () => {
cancelled = true;
};
}, []);
// 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)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
async function removeMatch() {
setIsSubmitting(true);
setError(null);
try {
const result = await apiClient.submitTechStepCorrection(recipeId, stepId, {
start: range.start,
end: range.end,
previousTechStepId,
correctedTechStepId: null,
});
onSubmitted(result);
onClose();
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setError(errorMessageService.getLabel(code));
setIsSubmitting(false);
}
}
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 ? (
<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>
{previousTechStepId !== null && (
<button
type="button"
disabled={isSubmitting}
onClick={removeMatch}
className="tech-step-correction-popover__remove"
>
{t("recipes.techStepCorrection.removeMatch")}
</button>
)}
</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}
<button
type="button"
onClick={() => removeIngredient(index)}
title={t("recipes.techStepCorrection.removeIngredient")}
disabled={isSubmitting}
>
</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"
className="tech-step-correction-popover__cancel"
onClick={onClose}
disabled={isSubmitting}
>
{t("recipes.techStepCorrection.cancel")}
</button>
</div>
);
}

View file

@ -18,8 +18,6 @@ export interface DescriptionSegment {
techStep: StepTechStepView["techStep"] | null;
/** Always `false` when `techStep` is `null`. */
isKeyword: boolean;
/** Mirrors the source `StepTechStepView.source` this segment came from — `null` when `techStep` is `null` (nothing to attribute a source to). See `StepDescription.tsx` for how `"auto"` vs `"manual"` render differently. */
source: StepTechStepView["source"] | null;
}
/**
@ -28,54 +26,34 @@ export interface DescriptionSegment {
* (the keyword) and, when present, `contextStart`/`contextEnd` (the wider
* clause it was found in see `StepTechStepView`, resolved server-side by
* `tech-step-matcher.ts`'s `matchTechStepSpans`). An entry with no context
* (older data, saved before that column pair existed, or a manual
* correction see `StepTechStep`'s schema doc comment) degrades to a
* keyword-only segment, same as before context spans existed at all.
* (older data, saved before that column pair existed see
* `StepTechStep`'s schema doc comment) degrades to a keyword-only segment,
* same as before context spans existed at all.
*
* `techSteps` is expected already sorted by `start` (the API returns it in
* `StepTechStep.order`, which *is* reading order see that model's schema
* doc comment) but this re-sorts defensively by each entry's own tight
* `start` rather than assuming it, and silently drops any entry whose own
* bounds don't make sense against `description` or a previously-accepted
* entry's own tight keyword span a malformed/out-of-date span degrades to
* "just don't highlight that one" rather than a garbled slice or a crash.
*
* Two entries' tight keyword spans are never allowed to overlap (the later
* one is dropped, same as always), but two entries' wider *context* clauses
* are allowed to overlap each other and are silently clipped to make room
* context is cosmetic only (`StepDescription.tsx` renders it identically to
* plain text) and must never cost a *different* entry its own real keyword
* highlight. This matters far more than it looks: a clause `splitIntoClauses`
* (`tech-step-matcher.ts`) found only one NER candidate in gets that
* candidate's context spanning the *entire* clause often the entire
* description so without clipping, a single auto-detected match anywhere
* in a step could silently swallow every manual correction added anywhere
* else in that same step's description, with no error, just an unstyled
* word in the rendered text. Found via live testing: a "simmer" match's
* whole-description context ate a manual "setAside" correction added to a
* later, otherwise-plain word in the same step.
* doc comment) but this re-sorts defensively (by context start when
* present, since context always starts at or before its own keyword)
* rather than assuming it, and silently drops any entry whose bounds don't
* make sense against `description` or a previously-accepted entry's own
* bounds a malformed/out-of-date span degrades to "just don't highlight
* that one" rather than a garbled slice or a crash.
*/
export function splitDescriptionByTechSteps(
description: string,
techSteps: StepTechStepView[],
): DescriptionSegment[] {
const sorted = [...techSteps].sort((a, b) => a.start - b.start);
const sorted = [...techSteps].sort(
(a, b) => (a.contextStart ?? a.start) - (b.contextStart ?? b.start),
);
// First pass: decide which entries survive at all, using only each
// entry's own tight keyword span for the cross-entry overlap check
// (`start < keywordCursor`) — the one thing two independent matches are
// never allowed to genuinely share. An entry's own context is still
// validated against its *own* keyword span here (`wideStart > start`,
// `end > wideEnd`, `wideEnd > description.length`) — a self-inconsistent
// span is dropped regardless of any other entry.
const valid: StepTechStepView[] = [];
let keywordCursor = 0;
for (const entry of sorted) {
const { start, end, contextStart, contextEnd } = entry;
const segments: DescriptionSegment[] = [];
let cursor = 0;
for (const { techStep, start, end, contextStart, contextEnd } of sorted) {
const wideStart = contextStart ?? start;
const wideEnd = contextEnd ?? end;
if (
start < keywordCursor ||
wideStart < cursor ||
wideStart > start ||
start >= end ||
end > wideEnd ||
@ -83,31 +61,12 @@ export function splitDescriptionByTechSteps(
) {
continue;
}
valid.push(entry);
keywordCursor = end;
}
const segments: DescriptionSegment[] = [];
let cursor = 0;
for (const [index, entry] of valid.entries()) {
const { techStep, start, end, contextStart, contextEnd, source } = entry;
const next = valid[index + 1];
// Clipped against `cursor` (this entry can't render context over
// territory already emitted) and the next surviving entry's own tight
// `start` (this entry's context can't reach into a neighbor's real
// keyword span) — provably within `[cursor, start]`/`[end, next.start]`
// respectively given `valid`'s own non-overlapping-tight-span
// invariant from the first pass, so never produces a negative-length
// slice.
const wideStart = Math.max(contextStart ?? start, cursor);
const wideEnd = Math.min(contextEnd ?? end, next?.start ?? description.length);
if (wideStart > cursor) {
segments.push({
text: description.slice(cursor, wideStart),
techStep: null,
isKeyword: false,
source: null,
});
}
if (start > wideStart) {
@ -115,27 +74,16 @@ export function splitDescriptionByTechSteps(
text: description.slice(wideStart, start),
techStep,
isKeyword: false,
source,
});
}
segments.push({ text: description.slice(start, end), techStep, isKeyword: true, source });
segments.push({ text: description.slice(start, end), techStep, isKeyword: true });
if (wideEnd > end) {
segments.push({
text: description.slice(end, wideEnd),
techStep,
isKeyword: false,
source,
});
segments.push({ text: description.slice(end, wideEnd), techStep, isKeyword: false });
}
cursor = wideEnd;
}
if (cursor < description.length) {
segments.push({
text: description.slice(cursor),
techStep: null,
isKeyword: false,
source: null,
});
segments.push({ text: description.slice(cursor), techStep: null, isKeyword: false });
}
return segments;
}

View file

@ -1,84 +0,0 @@
import { type RefObject, useCallback } from "react";
/** A `[start, end)` character range into a step's original `description` string — same convention as `StepTechStepView.start`/`end`. */
export interface TextSelectionRange {
start: number;
end: number;
}
/**
* Reads the browser's current text selection, translated into a
* {@link TextSelectionRange} into a step's original `description` string
* the shape `POST /recipes/:id/steps/:stepId/corrections` expects (see
* `SubmitTechStepCorrectionInput`, `packages/shared`).
*
* Works by walking up from each end of the selection's `Range` to the
* nearest ancestor carrying a `data-offset` attribute set by
* `StepDescription`'s editable mode on every {@link DescriptionSegment}'s
* own wrapping element (`<span>`/`<button>`, `StepDescription.tsx`), each
* wrapping exactly its own text run and nothing else. `data-offset`'s value
* is that segment's own absolute start offset into `description`; added to
* the in-node offset the `Range` reports, this gives an exact absolute
* offset without needing to serialize/re-measure any text.
*
* Deliberately *not* using the more common `Range.toString().length`-from-
* the-container's-start technique for this problem `StepDescription`
* always renders a `Tooltip` bubble alongside a keyword segment's own
* `<button>` (`Tooltip.tsx`, hidden via CSS, not removed from the DOM),
* whose text would silently pad that count past any keyword segment,
* corrupting every offset downstream of one.
*/
export function useTextSelection(containerRef: RefObject<HTMLElement | null>): {
getSelectionRange: () => TextSelectionRange | null;
} {
const getSelectionRange = useCallback((): TextSelectionRange | null => {
const selection = window.getSelection();
const container = containerRef.current;
if (!selection || selection.isCollapsed || selection.rangeCount === 0 || !container) {
return null;
}
const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) return null;
const start = resolveOffset(container, range.startContainer, range.startOffset);
const end = resolveOffset(container, range.endContainer, range.endOffset);
if (start === null || end === null || start === end) return null;
return start < end ? { start, end } : { start: end, end: start };
}, [containerRef]);
return { getSelectionRange };
}
/**
* Resolves `(node, nodeOffset)` (one end of a DOM `Range`, in the DOM's own
* mixed node/character-index convention) to an absolute character offset
* into `description`, or `null` if `node` isn't inside a segment
* `StepDescription` wrapped with `data-offset` at all a selection edge
* that lands on whitespace/structure outside any segment shouldn't occur
* given every segment is wrapped, but this degrades to "no valid
* selection" rather than a wrong span or a crash if it somehow does.
*/
function resolveOffset(container: HTMLElement, node: Node, nodeOffset: number): number | null {
// A `Range` boundary that lands exactly on a segment's own wrapping
// element (rather than diving into its single Text child) reports
// `nodeOffset` as a *child index* (`0` or `1`, since every segment wraps
// exactly one Text node) — not a character offset. Resolved to the
// matching character offset (segment start vs. segment end) up front, so
// the walk below only ever deals in character offsets from here on.
let charOffset = nodeOffset;
if (node instanceof HTMLElement) {
const textChild = node.firstChild;
charOffset = nodeOffset > 0 ? (textChild?.textContent?.length ?? 0) : 0;
}
let current: Node | null = node;
while (current && current !== container) {
if (current instanceof HTMLElement && current.dataset.offset !== undefined) {
return Number(current.dataset.offset) + charOffset;
}
current = current.parentNode;
}
return null;
}

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",
@ -42,10 +23,6 @@
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
"UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas",
"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"
},
"auth": {
@ -122,6 +99,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",
@ -164,30 +160,6 @@
"cancelDeleteButton": "Annuler",
"ingredientsTitle": "Ingrédients",
"stepsTitle": "Préparation",
"techStepCorrection": {
"selectionLabel": "« {{text}} »",
"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"
},
"tabs": {
"favoris": "Favoris",
"perso": "Perso",
@ -202,11 +174,10 @@
"sources": {
"searchPlaceholder": "Rechercher…",
"empty": "Aucune recette trouvée.",
"loadMore": "Voir plus",
"alreadyImported": "Déjà importée",
"loading": "Chargement…",
"loadError": "Impossible de charger cette source pour le moment.",
"loadMoreError": "Impossible de charger la suite pour le moment.",
"retry": "Réessayer",
"detail": {
"viewSource": "Voir sur le site d'origine"
},
@ -307,8 +278,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 +413,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
@ -42,92 +45,8 @@ services:
# authenticated request 401s despite login succeeding. See its doc
# comment in apps/api/src/config/env.ts.
COOKIE_SECURE: ${COOKIE_SECURE:-}
# Shared with the `tech-step-llm-worker` service below — see
# requireInternalWorker's doc comment
# (apps/api/src/middlewares/require-internal-worker.ts). Unset by
# 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
# long-lived process with no exposed port (nothing ever calls *into* it,
# it only ever calls out to `app`). Optional: an `INTERNAL_WORKER_SECRET`-
# less deployment can omit this service entirely and `app` still runs
# fine, just without the offline audit/feedback-loop jobs.
tech-step-llm-worker:
build:
context: .
dockerfile: services/tech-step-llm-worker/Dockerfile
restart: unless-stopped
depends_on:
- app
environment:
API_BASE_URL: "http://app:3000"
INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:?set INTERNAL_WORKER_SECRET in .env to run this service}
TECH_STEP_WORKER_CRON: ${TECH_STEP_WORKER_CRON:-0 3 * * 0}
volumes:
# GGUF weights persist across restarts — see this service's own
# Dockerfile doc comment on its VOLUME declaration.
- tech_step_llm_worker_models:/worker/models
volumes:
postgres_data:
tech_step_llm_worker_models:

View file

@ -1,661 +0,0 @@
/**
* French display/matching labels for the `Ingredient` reference catalog
* (`apps/api/src/db/reference-seed-data.ts`'s `INGREDIENT_GROUPS`), keyed
* by the same `Ingredient.key` used throughout the app the French
* counterpart to {@link INGREDIENT_LABELS_EN} (catalog-labels-en.ts), used
* by `apps/api/src/lib/recipe-matching/ingredient-matcher.ts` to resolve
* free-text ingredient lines from French-language recipe sources (Marmiton,
* 750g, Manger Bouger) against our catalog.
*
* Deliberately **not** hand-authored from scratch: every value here is
* copied verbatim from `apps/web/src/locales/fr/translation.json`'s
* `catalog.ingredients` (the same French names already shown in the UI),
* not written fresh for matching purposes the way {@link INGREDIENT_LABELS_EN}
* was. That reuse is a deliberate trade-off, not an oversight: it guarantees
* every one of this catalog's ~550 ingredients gets *some* French matching
* label for free, at the cost of a few labels being phrased for display
* (a UI-friendly short name) rather than for how someone would actually
* write it in running recipe text e.g. `vanillaBean`'s "Vanille (gousse)"
* won't match "1 gousse de vanille" (matching is ordered see
* `matchIngredientName`'s own doc comment and "gousse" comes first in
* real usage, not "vanille"), which is exactly what
* {@link INGREDIENT_LABEL_SYNONYMS_FR} below exists to patch up, entry by
* entry, as real mismatches like that one turn up the ingredient still
* resolves correctly once a synonym in the natural word order is added, no
* change to the primary (display) label required.
*/
export const INGREDIENT_LABELS_FR: Record<string, string> = {
// Vegetables
tomato: "Tomate",
onion: "Oignon",
shallot: "Échalote",
garlic: "Ail",
carrot: "Carotte",
zucchini: "Courgette",
cucumber: "Concombre",
gherkins: "Cornichons",
bellPepper: "Poivron",
mushroom: "Champignon",
porcini: "Cèpes",
eggplant: "Aubergine",
broccoli: "Brocoli",
cauliflower: "Chou-fleur",
whiteCabbage: "Chou blanc",
redCabbage: "Chou rouge",
brusselsSprouts: "Chou de Bruxelles",
spinach: "Épinard",
swissChard: "Blette",
lettuce: "Salade",
arugula: "Roquette",
watercress: "Cresson",
leek: "Poireau",
celery: "Céleri",
radish: "Radis",
beetroot: "Betterave",
turnip: "Navet",
parsnip: "Panais",
greenBean: "Haricot vert",
pea: "Petit pois",
corn: "Maïs",
artichoke: "Artichaut",
fennel: "Fenouil",
endive: "Endive",
pumpkin: "Potiron",
butternutSquash: "Butternut",
asparagus: "Asperge",
avocado: "Avocat",
potato: "Pomme de terre",
sweetPotato: "Patate douce",
cherryTomato: "Tomates cerises",
bokChoy: "Pak-choï",
soybeanSprouts: "Germes de soja",
shiitake: "Shiitake",
daikon: "Daikon",
freshGreenChili: "Piment vert frais",
cardoon: "Cardon",
radicchio: "Chicorée rouge",
romanesco: "Chou romanesco",
kohlrabi: "Chou-rave",
napaCabbage: "Chou chinois",
celeriac: "Céleri-rave",
okra: "Gombo",
springOnion: "Oignon nouveau",
redKuriSquash: "Potimarron",
rutabaga: "Rutabaga",
samphire: "Salicorne",
salsify: "Salsifis",
lambsLettuce: "Mâche",
escarole: "Scarole",
// Fruits
lemon: "Citron",
lime: "Citron vert",
apple: "Pomme",
pear: "Poire",
banana: "Banane",
orange: "Orange",
clementine: "Clémentine",
grapefruit: "Pamplemousse",
strawberry: "Fraise",
raspberry: "Framboise",
blueberry: "Myrtille",
blackberry: "Mûre",
cherry: "Cerise",
apricot: "Abricot",
peach: "Pêche",
plum: "Prune",
grape: "Raisin",
melon: "Melon",
watermelon: "Pastèque",
pineapple: "Ananas",
mango: "Mangue",
kiwi: "Kiwi",
fig: "Figue",
date: "Datte",
lychee: "Litchi",
pomegranate: "Grenade",
rhubarb: "Rhubarbe",
quince: "Coing",
blackcurrant: "Cassis",
cranberry: "Canneberge",
redcurrant: "Groseille",
persimmon: "Kaki",
nectarine: "Nectarine",
tamarind: "Tamarin",
// Fresh herbs
basil: "Basilic",
parsley: "Persil",
thyme: "Thym",
rosemary: "Romarin",
bayLeaf: "Laurier",
chives: "Ciboulette",
freshCilantro: "Coriandre fraîche",
mint: "Menthe",
oregano: "Origan",
dill: "Aneth",
tarragon: "Estragon",
savory: "Sarriette",
marjoram: "Marjolaine",
sage: "Sauge",
chervil: "Cerfeuil",
ginger: "Gingembre",
lemongrass: "Citronnelle",
kaffirLime: "Combava",
// Meats
rabbit: "Lapin",
groundBeef: "Bœuf haché",
beefSteak: "Steak de bœuf",
beefRoast: "Rôti de bœuf",
vealCutlet: "Escalope de veau",
porkTenderloin: "Filet mignon de porc",
porkChop: "Côte de porc",
groundVeal: "Veau haché",
groundPork: "Porc haché",
groundLamb: "Agneau haché",
lamb: "Agneau",
legOfLamb: "Gigot d'agneau",
baconLardons: "Lardons",
bacon: "Bacon",
ham: "Jambon blanc",
curedHam: "Jambon cru",
sausage: "Saucisse",
chorizo: "Chorizo",
merguez: "Merguez",
prosciutto: "Prosciutto",
pancetta: "Pancetta",
mortadella: "Mortadelle",
salami: "Salami",
andouille: "Andouille",
andouillette: "Andouillette",
whitePudding: "Boudin blanc",
blackPudding: "Boudin noir",
cervelat: "Cervelas",
rillettes: "Rillettes",
dryCuredSausage: "Saucisson sec",
bayonneHam: "Jambon de Bayonne",
coppa: "Coppa",
rosetteSausage: "Rosette (saucisson)",
vealLiver: "Foie de veau",
vealKidneys: "Rognons de veau",
vealBrain: "Cervelle de veau",
vealSweetbread: "Ris de veau",
beefTongue: "Langue de bœuf",
tripe: "Tripes",
venison: "Cerf",
roeDeer: "Chevreuil",
wildBoar: "Sanglier",
horseMeat: "Cheval",
beefHeart: "Cœur de bœuf",
foieGras: "Foie gras",
beefMuzzle: "Museau de bœuf",
grisonsDriedBeef: "Viande des Grisons",
// Poultry
chicken: "Poulet",
groundChicken: "Poulet haché",
turkey: "Dinde",
groundTurkey: "Dinde hachée",
duck: "Canard",
duckBreast: "Magret de canard",
quail: "Caille",
guineaFowl: "Pintade",
goose: "Oie",
poultryLiver: "Foie de volaille",
capon: "Chapon",
pigeon: "Pigeon",
pheasant: "Faisan",
// Fish
salmon: "Saumon",
tuna: "Thon",
cod: "Cabillaud",
trout: "Truite",
sardine: "Sardine",
anchovy: "Anchois",
whiting: "Merlan",
surimi: "Surimi",
seaBass: "Bar (loup de mer)",
seaBream: "Dorade",
sole: "Sole",
turbot: "Turbot",
hake: "Merlu",
pollock: "Colin",
saithe: "Lieu noir",
haddock: "Églefin",
mackerel: "Maquereau",
herring: "Hareng",
redMullet: "Rouget",
skate: "Raie",
monkfish: "Lotte",
halibut: "Flétan",
swordfish: "Espadon",
carp: "Carpe",
pike: "Brochet",
perch: "Perche",
tilapia: "Tilapia",
pangasius: "Panga",
smokedSalmon: "Saumon fumé",
driedFish: "Poisson séché",
eel: "Anguille",
plaice: "Carrelet (ou plie)",
saltCod: "Morue",
lemonSole: "Limande",
scorpionfish: "Rascasse",
// Shellfish
shrimp: "Crevettes",
langoustine: "Langoustines",
lobster: "Homard",
crab: "Crabe",
spinyLobster: "Langouste",
mussels: "Moules",
oysters: "Huîtres",
scallops: "Saint-Jacques",
squid: "Calamar",
octopus: "Poulpe",
clams: "Palourdes",
whelks: "Bulots",
spiderCrab: "Araignée de mer",
periwinkle: "Bigorneau",
crayfish: "Écrevisse",
greyShrimp: "Crevette grise",
cockle: "Coque",
snail: "Escargot",
cuttlefish: "Seiche",
// Starches
semolina: "Semoule",
couscous: "Couscous",
bulgur: "Boulgour",
polenta: "Polenta",
quinoa: "Quinoa",
pasta: "Pâtes",
wholeWheatPasta: "Pâtes complètes",
rice: "Riz",
basmatiRice: "Riz basmati",
brownRice: "Riz complet",
oats: "Flocons d'avoine",
spaghetti: "Spaghetti",
penne: "Penne",
tagliatelle: "Tagliatelles",
lasagnaSheets: "Lasagnes (feuilles)",
gnocchi: "Gnocchi",
arborioRice: "Riz arborio",
riceNoodles: "Nouilles de riz",
udonNoodles: "Nouilles udon",
sobaNoodles: "Nouilles soba",
chineseNoodles: "Nouilles chinoises",
riceVermicelli: "Vermicelles de riz",
soyVermicelli: "Vermicelles de soja",
stickyRice: "Riz gluant",
sushiRice: "Riz à sushi",
jasmineRice: "Riz jasmin",
// Legumes
greenLentils: "Lentilles vertes",
redLentils: "Lentilles corail",
chickpeas: "Pois chiches",
whiteBeans: "Haricots blancs",
kidneyBeans: "Haricots rouges",
blackBeans: "Haricots noirs",
splitPeas: "Pois cassés",
favaBeans: "Fèves",
edamame: "Edamame",
pintoBeans: "Haricots pinto",
flageoletBeans: "Haricots flageolets",
goldenLentils: "Lentilles blondes",
// Nuts, seeds and other dry goods
peanutsShelled: "Cacahuètes",
almonds: "Amandes",
walnuts: "Noix",
hazelnuts: "Noisettes",
cashews: "Noix de cajou",
pistachios: "Pistaches",
pecans: "Noix de pécan",
almondPowder: "Poudre d'amande",
pineNuts: "Pignons de pin",
sunflowerSeeds: "Graines de tournesol",
pumpkinSeeds: "Graines de courge",
shreddedCoconut: "Noix de coco râpée",
raisins: "Raisins secs",
prunes: "Pruneaux",
driedApricots: "Abricots secs",
sesameSeeds: "Graines de sésame",
blackMushrooms: "Champignons noirs",
noriSeaweed: "Algue nori",
wakameSeaweed: "Algue wakamé",
kombuSeaweed: "Algue kombu",
bambooShoots: "Pousses de bambou",
waterChestnuts: "Châtaignes d'eau",
// Breads
bread: "Pain",
sandwichBread: "Pain de mie",
wholeWheatBread: "Pain complet",
baguette: "Baguette",
ryeBread: "Pain de seigle",
breadcrumbs: "Chapelure",
burgerBun: "Pain à burger",
briocheBun: "Pain brioché",
hotDogBun: "Pain à hot-dog",
pitaBread: "Pain pita",
bagel: "Pain bagel",
naan: "Naan",
wrapBread: "Pain wrap",
vienneseBread: "Pain viennois",
countryBread: "Pain de campagne",
multigrainBread: "Pain aux céréales",
breadRoll: "Petit pain",
swedishBread: "Pain suédois",
glutenFreeBread: "Pain sans gluten",
rusk: "Biscotte",
croutons: "Croûtons",
focaccia: "Focaccia",
ciabatta: "Ciabatta",
cornTortilla: "Tortilla de maïs",
wheatTortilla: "Tortilla de blé",
breadstick: "Gressin",
// Raw dough
puffPastry: "Pâte feuilletée",
shortcrustPastry: "Pâte brisée",
pizzaDough: "Pâte à pizza",
sweetShortcrustPastry: "Pâte à tarte sablée",
// Dairy
milk: "Lait",
butter: "Beurre",
cremeFraiche: "Crème fraîche",
liquidCream: "Crème liquide",
cheese: "Fromage",
emmental: "Emmental",
gruyere: "Gruyère",
parmesan: "Parmesan",
mozzarella: "Mozzarella",
goatCheese: "Chèvre (fromage)",
feta: "Feta",
comte: "Comté",
fromageBlanc: "Fromage blanc",
mascarpone: "Mascarpone",
yogurt: "Yaourt",
burrata: "Burrata",
ricotta: "Ricotta",
pecorino: "Pecorino",
gorgonzola: "Gorgonzola",
cheddar: "Cheddar",
brie: "Brie",
camembert: "Camembert",
roquefort: "Roquefort",
munster: "Munster",
reblochon: "Reblochon",
cantal: "Cantal",
beaufort: "Beaufort",
saintNectaire: "Saint-Nectaire",
blueCheese: "Bleu (fromage)",
cancoillotte: "Cancoillotte",
tomme: "Tomme",
epoisses: "Époisses",
chaource: "Chaource",
livarot: "Livarot",
pontLeveque: "Pont-l'Évêque",
morbier: "Morbier",
racletteCheese: "Raclette (fromage)",
fourmeDAmbert: "Fourme d'Ambert",
salers: "Salers",
ossauIraty: "Ossau-Iraty",
vacherin: "Vacherin",
saintMarcellin: "Saint-Marcellin",
neufchatel: "Neufchâtel",
crottinDeChavignol: "Crottin de Chavignol",
abondanceCheese: "Abondance",
carreDeLEst: "Carré de l'Est",
edam: "Edam",
gouda: "Gouda",
mimolette: "Mimolette",
maroilles: "Maroilles",
montDor: "Mont d'or",
kefir: "Kéfir",
greekYogurt: "Yaourt à la grecque",
// Eggs
egg: "Oeuf",
eggYolk: "Jaune d'oeuf",
eggWhite: "Blanc d'oeuf",
// Plant-based alternatives
coconutMilk: "Lait de coco",
coconutCream: "Crème de coco",
almondMilk: "Lait d'amande",
oatMilk: "Lait d'avoine",
tofu: "Tofu",
silkenTofu: "Tofu soyeux",
// Spices
herbesDeProvence: "Herbes de Provence",
blackPepper: "Poivre noir",
paprika: "Paprika",
espelettePepper: "Piment d'Espelette",
cayennePepper: "Piment de Cayenne",
cumin: "Cumin",
curryPowder: "Curry (poudre)",
turmeric: "Curcuma",
cinnamon: "Cannelle",
nutmeg: "Muscade",
saffron: "Safran",
clove: "Clou de girofle",
vanillaBean: "Vanille (gousse)",
whitePepper: "Poivre blanc",
pinkPepper: "Poivre rose",
sichuanPepper: "Poivre du Sichuan",
smokedPaprika: "Paprika fumé",
birdEyeChili: "Piment oiseau",
juniperBerries: "Baies de genièvre",
starAnise: "Anis étoilé (badiane)",
greenAnise: "Anis vert",
fennelSeeds: "Graines de fenouil",
sumac: "Sumac",
nigella: "Nigelle",
allspice: "Quatre épices",
colomboPowder: "Colombo (poudre)",
baharat: "Baharat",
horseradish: "Raifort",
herbSalt: "Sel aux herbes",
celerySalt: "Sel de céleri",
fleurDeSel: "Fleur de sel",
salt: "Sel",
fiveSpice: "Cinq épices",
garamMasala: "Garam masala",
corianderSeeds: "Graines de coriandre",
groundCoriander: "Coriandre en poudre",
cardamom: "Cardamome",
fenugreek: "Fenugrec",
jalapeno: "Piment jalapeño",
chipotle: "Piment chipotle",
poblanoPepper: "Piment poblano",
habanero: "Piment habanero",
rasElHanout: "Ras el hanout",
zaatar: "Za'atar",
// Sauces
soySauce: "Sauce soja",
mustard: "Moutarde",
mayonnaise: "Mayonnaise",
ketchup: "Ketchup",
tabasco: "Tabasco",
worcestershireSauce: "Sauce Worcestershire",
fishSauce: "Sauce nuoc-mâm",
wasabi: "Wasabi",
harissa: "Harissa",
curryPaste: "Pâte de curry",
peanutButter: "Beurre de cacahuète",
dijonMustard: "Moutarde de Dijon",
wholegrainMustard: "Moutarde à l'ancienne",
barbecueSauce: "Sauce barbecue",
tartarSauce: "Sauce tartare",
cocktailSauce: "Sauce cocktail",
bearnaiseSauce: "Sauce béarnaise",
hollandaiseSauce: "Sauce hollandaise",
bechamelSauce: "Sauce béchamel",
teriyakiSauce: "Sauce teriyaki",
ponzuSauce: "Sauce ponzu",
chimichurri: "Chimichurri",
redPesto: "Pesto rouge (tomates séchées)",
pesto: "Pesto",
oysterSauce: "Sauce huître",
hoisinSauce: "Sauce hoisin",
sriracha: "Sauce sriracha",
sweetChiliSauce: "Sauce sweet chili",
miso: "Miso",
shrimpPaste: "Pâte de crevettes",
redCurryPaste: "Pâte de curry rouge (thaï)",
greenCurryPaste: "Pâte de curry vert (thaï)",
tahini: "Tahini",
aioli: "Aïoli",
vinaigrette: "Sauce vinaigrette",
hummus: "Houmous",
// Seasonings — oils, vinegars, wines and other flavorings
oliveOil: "Huile d'olive",
sunflowerOil: "Huile de tournesol",
rapeseedOil: "Huile de colza",
coconutOil: "Huile de coco",
sesameOil: "Huile de sésame",
ciderVinegar: "Vinaigre de cidre",
whiteVinegar: "Vinaigre blanc",
balsamicVinegar: "Vinaigre balsamique",
capers: "Câpres",
olives: "Olives",
blackOlives: "Olives noires",
greenOlives: "Olives vertes",
whiteWine: "Vin blanc (cuisine)",
redWine: "Vin rouge (cuisine)",
roseWine: "Vin rosé (cuisine)",
redWineVinegar: "Vinaigre de vin rouge",
whiteWineVinegar: "Vinaigre de vin blanc",
sherryVinegar: "Vinaigre de xérès",
walnutOil: "Huile de noix",
hazelnutOil: "Huile de noisette",
peanutOil: "Huile d'arachide",
chiliOil: "Huile pimentée",
riceVinegar: "Vinaigre de riz",
cornOil: "Huile de maïs",
grapeseedOil: "Huile de pépins de raisin",
soybeanOil: "Huile de soja",
palmOil: "Huile de palme",
mirin: "Mirin",
sake: "Saké (cuisine)",
lemonJuice: "Jus de citron",
limeJuice: "Jus de citron vert",
orangeJuice: "Jus d'orange",
appleJuice: "Jus de pomme",
grapeJuice: "Jus de raisin",
tomatoJuice: "Jus de tomate",
cranberryJuice: "Jus de cranberry",
coffee: "Café",
tea: "Thé",
beer: "Bière (cuisine)",
cider: "Cidre (cuisine)",
champagne: "Champagne / vin pétillant (cuisine)",
portWine: "Porto (cuisine)",
vinJaune: "Vin jaune (cuisine)",
cognac: "Cognac",
rum: "Rhum",
whisky: "Whisky",
vodka: "Vodka",
// Bases — flours, stocks and other cooking essentials
wheatFlour: "Farine de blé",
wholeWheatFlour: "Farine complète",
cornFlour: "Farine de maïs",
buckwheatFlour: "Farine de sarrasin",
riceFlour: "Farine de riz",
vegetableStockCube: "Bouillon cube légumes",
chickenStockCube: "Bouillon cube volaille",
tomatoPaste: "Concentré de tomate",
tomatoCoulis: "Coulis de tomate",
cannedPeeledTomatoes: "Tomates pelées (conserve)",
sunDriedTomatoes: "Tomates séchées",
vealStock: "Fond de veau",
chickenStock: "Fond de volaille",
beefStockCube: "Bouillon cube bœuf",
fishStockCube: "Bouillon cube poisson",
vegetableBroth: "Bouillon de légumes",
chickenBroth: "Bouillon de volaille",
beefBroth: "Bouillon de bœuf",
courtBouillon: "Court-bouillon",
dashi: "Dashi (bouillon japonais)",
shellfishBisque: "Bisque de crustacés",
tapiocaFlour: "Farine de tapioca",
masaHarina: "Masa harina",
water: "Eau",
sparklingWater: "Eau gazeuse",
orangeBlossomWater: "Eau de fleur d'oranger",
roseWater: "Eau de rose",
fishFumet: "Fumet de poisson",
// Thickeners and raising agents
bakersYeast: "Levure boulangère",
bakingPowder: "Levure chimique",
cornstarch: "Maïzena",
lupinFlour: "Farine de lupin",
gelatin: "Gélatine",
bakingSoda: "Bicarbonate de soude",
potatoStarch: "Fécule de pomme de terre",
// Sugars
sugar: "Sucre",
honey: "Miel",
mapleSyrup: "Sirop d'érable",
brownSugar: "Sucre roux",
powderedSugar: "Sucre glace",
demeraraSugar: "Cassonade",
darkChocolate: "Chocolat noir",
milkChocolate: "Chocolat au lait",
whiteChocolate: "Chocolat blanc",
chocolateChips: "Pépites de chocolat",
cocoaPowder: "Cacao en poudre",
vanillaExtract: "Extrait de vanille",
palmSugar: "Sucre de palme",
caneSyrup: "Sirop de sucre de canne",
};
/**
* Extra French matching phrases for a handful of {@link INGREDIENT_LABELS_FR}
* entries whose primary (display) label doesn't match how the phrase is
* actually written in running recipe text see that constant's own doc
* comment for why this is expected to grow over time, the same "exceptions
* only, not every entry" shape as {@link INGREDIENT_LABEL_SYNONYMS_EN}.
*/
export const INGREDIENT_LABEL_SYNONYMS_FR: Record<string, string[]> = {
// "Vanille (gousse)" is display-first-word-last; real recipe text says
// "gousse de vanille" (pod word first) — see INGREDIENT_LABELS_FR's doc
// comment.
vanillaBean: ["Gousse de vanille"],
};
/**
* French matching synonyms for the `Unit` reference catalog
* (`apps/api/src/db/reference-seed-data.ts`'s `UNITS`), keyed by
* `Unit.key` the French counterpart to {@link UNIT_LABELS_EN}. Unlike
* {@link INGREDIENT_LABELS_FR}, these are **not** copied from
* `apps/web`'s locale file: that file has exactly one display label per
* unit (`apps/web/src/locales/fr/translation.json`'s `catalog.units`,
* e.g. `tablespoon`: "cuillère à soupe"), fine for a dropdown but not
* enough to *match* French recipe text against real recipes freely mix
* the full phrase, its plural, and common abbreviations ("cuillère à
* soupe", "cuillères à soupe", "c. à soupe", "càs" all appear in the
* wild), so each entry here is hand-authored the same way
* {@link UNIT_LABELS_EN} was, starting from that same display label.
*
* Several of these are genuinely multi-word ("cuillère à soupe") unlike
* English units, which are always a single word/abbreviation. Matching a
* multi-word unit needs the same ordered-contiguous-run search
* `matchIngredientName` already does for ingredients, not the older
* single-first-word check `matchUnit` used before French support existed
* see `matchUnit`'s own doc comment (`ingredient-matcher.ts`) for the
* bug that would otherwise cause: a multi-word French synonym's *whole
* phrase* (spaces and all) would never equal a single extracted word, so
* it could never match anything at all.
*/
export const UNIT_LABELS_FR: Record<string, string[]> = {
gram: ["g", "gr", "gramme", "grammes"],
kilogram: ["kg", "kilo", "kilos", "kilogramme", "kilogrammes"],
milliliter: ["ml", "millilitre", "millilitres"],
centiliter: ["cl", "centilitre", "centilitres"],
liter: ["l", "litre", "litres"],
tablespoon: ["cuillère à soupe", "cuillères à soupe", "c. à soupe", "c à soupe", "cas", "càs"],
teaspoon: ["cuillère à café", "cuillères à café", "c. à café", "c à café", "cac", "càc"],
piece: ["unité", "unités", "pièce", "pièces"],
pinch: ["pincée", "pincées"],
slice: ["tranche", "tranches"],
clove: ["gousse", "gousses"],
bunch: ["botte", "bottes"],
sachet: ["sachet", "sachets"],
sprig: ["brin", "brins"],
cup: ["tasse", "tasses"],
ounce: ["once", "onces"],
pound: ["livre", "livres"],
};

View file

@ -64,14 +64,6 @@ export enum ErrorCode {
UNIT_NOT_FOUND = 4048,
/** `PATCH /house/current/sources`'s `sourceIds` contains one that doesn't match any reference `Source` row. */
SOURCE_NOT_FOUND = 4049,
/** `POST /recipes/:id/steps/:stepId/corrections` given a `stepId` that doesn't belong to a recipe visible to the caller. */
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. */
INTERNAL_ERROR = 5000,
}

View file

@ -4,7 +4,6 @@
// detail specific to one side.
export * from "./data/catalog-labels-en.js";
export * from "./data/catalog-labels-fr.js";
export * from "./errors/error-codes.js";
export * from "./schemas/account.js";
export * from "./schemas/auth.js";
@ -13,16 +12,12 @@ 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";
export * from "./types/household.js";
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

@ -116,97 +116,3 @@ 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
* `description` should (or shouldn't) be tagged with. `previousTechStepId`
* is the existing match being corrected (omit/`null` when the user is
* flagging a technique the classifier missed entirely nothing to
* correct, just to add); `correctedTechStepId` is what they assert instead
* (omit/`null` means "no technique belongs here", i.e. removing a wrong
* match). Rejecting both being absent at once happens service-side
* (`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({
start: z.number().int().nonnegative(),
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",
path: ["end"],
})
.refine(
(input) =>
(input.previousTechStepId ?? null) !== null || (input.correctedTechStepId ?? null) !== null,
{
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,54 +0,0 @@
import { z } from "zod";
/** Query params accepted by `GET /internal/tech-steps/audit-batch` and `GET /internal/tech-steps/pending-corrections` — both just a bound on how much work one call asks for, so the worker controls its own batch size rather than the server guessing. */
export const workerBatchQuerySchema = z.object({
limit: z.coerce.number().int().positive().max(500).default(50),
});
/** Inferred TS type for {@link workerBatchQuerySchema}'s validated output. */
export type WorkerBatchQueryInput = z.infer<typeof workerBatchQuerySchema>;
/** `locale` param `GET /internal/tech-steps/audit-batch` also accepts, on top of {@link workerBatchQuerySchema} — which of `TECH_STEP_TRAINING_DATA`'s locales to sample steps' clauses against (see `tech-step-matcher.ts`'s `matchTechStepSpans` for the same parameter on the read side). No closed enum here (unlike `recipeVisibilitySchema`) — the training data's own locale list can grow without a schema change. */
export const auditBatchQuerySchema = workerBatchQuerySchema.extend({
locale: z.string().min(2).default("fr"),
});
/** Inferred TS type for {@link auditBatchQuerySchema}'s validated output. */
export type AuditBatchQueryInput = z.infer<typeof auditBatchQuerySchema>;
/**
* One suggestion in the batch `POST /internal/tech-steps/training-suggestions`
* accepts `techStepKey` (not an id) since the worker never has direct DB
* access to resolve one itself; the API resolves it, and rejects the whole
* batch with `TECH_STEP_NOT_FOUND` if any key is unknown (see
* `tech-step-worker.service.ts`). `sourceCorrectionId` is required when
* `sourceType` is `"correction"` (that's the whole point of that source
* it exists *because of* one specific correction) and must be absent
* otherwise enforced by the refinement below, not by two separate
* schemas, so the error message can point at exactly which field is wrong.
*/
const trainingSuggestionSchema = z
.object({
techStepKey: z.string().min(1),
locale: z.string().min(2),
suggestedSynonyms: z.array(z.string().trim().min(1)),
suggestedUtterances: z.array(z.string().trim().min(1)),
sourceType: z.enum(["correction", "llm_audit"]),
sourceCorrectionId: z.number().int().positive().nullable().optional(),
})
.refine(
(input) =>
input.sourceType === "correction"
? input.sourceCorrectionId !== null && input.sourceCorrectionId !== undefined
: input.sourceCorrectionId === null || input.sourceCorrectionId === undefined,
{
message:
"sourceCorrectionId is required when sourceType is 'correction', and must be absent otherwise",
path: ["sourceCorrectionId"],
},
);
/** Payload accepted by `POST /internal/tech-steps/training-suggestions` — a batch, not one suggestion per call, since the worker's audit/correction jobs naturally produce several at once per run and there's no reason to round-trip once per suggestion. */
export const submitTrainingSuggestionsSchema = z.object({
suggestions: z.array(trainingSuggestionSchema).min(1),
});
/** Inferred TS type for {@link submitTrainingSuggestionsSchema}'s validated output. */
export type SubmitTrainingSuggestionsInput = z.infer<typeof submitTrainingSuggestionsSchema>;

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.
@ -50,17 +43,6 @@ export interface RecipeIngredientView {
* and not yet recomputed (see `StepTechStep`'s schema doc comment) a
* caller with no context just shows the keyword highlight alone, same as
* before these existed.
*
* `source` mirrors `StepTechStep.source` (schema.prisma) `"auto"` is the
* classifier's own detection, `"manual"` is a viewer's correction applied
* 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;
@ -68,39 +50,6 @@ export interface StepTechStepView {
end: number;
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";
}
/**
@ -150,42 +99,3 @@ export interface RecipeView extends RecipeSummaryView {
ingredients: RecipeIngredientView[];
steps: StepView[];
}
/**
* One user-submitted correction to a step's detected techniques, as
* returned by `GET /recipes/:id/steps/:stepId/corrections` the read side
* of `POST` on the same route (`submitTechStepCorrectionSchema`,
* `packages/shared/src/schemas/recipe.ts`). `previousTechStep`/
* `correctedTechStep` are resolved to their reference data (same "resolve
* at read time" treatment as {@link StepTechStepView.techStep}) rather than
* bare ids `null` carries the same "missing"/"none" meaning documented on
* `StepTechStepCorrection` in schema.prisma. Not shown to *every* viewer of
* a step by default in `apps/web` today (see `StepDescription.tsx`)
* mainly useful for a user checking what they (or others) already
* submitted before adding another correction to the same span.
*/
export interface StepTechStepCorrectionView {
id: number;
start: number;
end: number;
previousTechStep: TechStepView | null;
correctedTechStep: TechStepView | null;
createdAt: string;
}
/**
* Response of `POST /recipes/:id/steps/:stepId/corrections` the audit
* record just created, plus the step's fresh, immediately up-to-date
* `techSteps` sequence (`StepView.techSteps`'s own shape) after applying
* it. `apps/web`'s `StepDescription.tsx` replaces its local copy of the
* step's `techSteps` with this on a successful submit, so the new/relabeled
* `"manual"`-sourced highlight appears right away the API is the single
* source of truth for exactly which entry changed and how (a relabel
* updates one row in place, an add inserts one, a remove deletes one; see
* `recipe-tech-step-correction.service.ts`'s `applyManualCorrection`), so
* the frontend never tries to replicate that logic client-side.
*/
export interface SubmitTechStepCorrectionResult {
correction: StepTechStepCorrectionView;
techSteps: StepTechStepView[];
}

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

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