Compare commits

..

No commits in common. "main" and "fix/isolate-test-database" have entirely different histories.

244 changed files with 4414 additions and 27669 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

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

12
.gitignore vendored
View file

@ -71,13 +71,6 @@ 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/
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
@ -158,8 +151,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

@ -1,3 +0,0 @@
# Instructions du projet
Avant toute action de génération de code, lis attentivement le fichier `specs\dev-conventions.md` pour prendre connaissance de l'ensemble des normes de développement à appliquer impérativement

418
README.md
View file

@ -4,21 +4,13 @@
Monorepo pnpm workspaces :
- `apps/api` — backend Express/TypeScript : auth, foyer, planning (grille de la
semaine), catalogue de recettes (favoris/perso/foyer/publique), import de
recettes depuis des sources externes, préférences (thème, régime, allergies,
ingrédients détestés). Tests Mocha (base Postgres réelle, isolée de la base
de dev — voir plus bas).
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par
Capacitor plus tard. Espace connecté complet (planning, recettes, réglages)
derrière une sidebar, wizard d'inscription, thème clair/sombre/système.
- `packages/shared` — code partagé entre `api` et `web` : schémas zod, types
(`RecipeView`, `PlanningView`, `HouseView`, `SafeUserProfile`...), le contrat
d'erreurs (`ErrorCode` numérique, `ApiErrorResponse`, voir
[specs/error-handling.md](specs/error-handling.md)) et les libellés anglais
du catalogue d'ingrédients (`data/catalog-labels-en.ts`, utilisés par le
matching de recettes importées — voir plus bas) — même règles des deux
côtés, pas de risque de dérive entre front et back.
- `apps/api` — backend Express/TypeScript (squelette générique : healthcheck, config env, Prisma non modélisé, tests Mocha)
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par Capacitor plus tard.
Page de connexion/inscription en place ; le reste est encore un squelette générique.
- `packages/shared` — code partagé entre `api` et `web` : schémas zod (`signupSchema`,
`loginSchema`), types (`SafeUserProfile`), et le contrat d'erreurs (`ErrorCode`
numérique, `ApiErrorResponse`, voir [specs/error-handling.md](specs/error-handling.md)) —
même règles des deux côtés, pas de risque de dérive entre front et back.
- `packages/error-tools` — gestion des erreurs, **indépendante de tout framework
HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Séparé
d'`express-tools` précisément parce que rien ici ne dépend d'Express. Détail :
@ -27,16 +19,11 @@ Monorepo pnpm workspaces :
(init serveur, routes, middlewares), `wrapAsyncHandler`, `createErrorMiddleware`
(adapte `ErrorHandlerService` de `error-tools` à Express) — séparé d'`apps/api`,
pas de logique métier. Détail : [specs/backend-architecture.md](specs/backend-architecture.md).
- `packages/date-tools` — utilitaires de date partagés (Luxon) : convention
"date-only = minuit UTC" (`parseDateOnly`/`formatDateOnly`/`toDateOnly`),
calcul de semaine lundi-first (`getWeekStart`/`addWeeks`/`buildCalendarMonth`)
— utilisés à la fois par `apps/api` (validation de date de planning) et
`apps/web` (grille/navigateur de semaine).
`packages/shared`, `packages/error-tools`, `packages/express-tools` et
`packages/date-tools` ont un vrai build (`tsc` → `dist/`, voir leur
`package.json`) : consommés en JS compilé, pas en TS brut — nécessaire pour un
runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans
`packages/shared`, `packages/error-tools` et `packages/express-tools` ont un vrai
build (`tsc` → `dist/`, voir leur `package.json`) : consommés en JS compilé, pas en
TS brut — nécessaire pour un runtime Node pur (Docker, pas de transpilation à la
volée), voir la note dans
[specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts).
## Prérequis
@ -44,8 +31,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
@ -64,9 +49,6 @@ sont pas définis dans `.env` — pas de valeur par défaut en dur dans les fich
Même règle pour `apps/api/.env` : `JWT_SECRET` est **requis, sans défaut** (génère le
tien, voir le commentaire dans `apps/api/.env.example`).
Si tu comptes lancer `pnpm --filter api test` (voir [Qualité / Tests](#qualité--tests)),
crée aussi `apps/api/.env.test` — voir la section dédiée plus bas.
### Cypress : téléchargement du binaire
`pnpm install` installe le package `cypress` mais **pas forcément son binaire** (le
@ -98,21 +80,6 @@ docker compose up -d postgres
# Applique le schéma (première fois / après un changement de prisma/schema.prisma)
pnpm --filter api exec prisma migrate dev
# Peuple les données de référence (régimes, allergènes, ingrédients, unités,
# 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
@ -137,46 +104,15 @@ pnpm dev:web
## Qualité / Tests
Conventions de code (classes vs objets littéraux, préfixe `_` sur les membres
privés, règles Biome actives, logs côté serveur, etc.) :
[specs/dev-conventions.md](specs/dev-conventions.md).
```bash
pnpm lint # Biome (lint + format check)
pnpm lint:fix # Biome --write
pnpm test # tests unitaires/intégration (Mocha, apps/api)
pnpm --filter web e2e # tests e2e (Cypress + Cucumber, démarre le serveur dev automatiquement)
pnpm --filter web cy:run:component # tests de composant UI isolés (Cypress component testing)
pnpm --filter web e2e # tests e2e (Cypress, démarre le serveur dev automatiquement)
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`).
### Base de test isolée de la base de dev (`apps/api`)
`pnpm --filter api test` exécute une `TRUNCATE ... CASCADE` sur presque tout le
schéma **avant chaque test** (`test-support/reset-db.ts`). Pour ne jamais
risquer de vider une vraie base de dev locale, `NODE_ENV=test` (posé par le
script `test`) fait charger `apps/api/.env.test` au lieu de `.env` — un
fichier **à créer toi-même**, pas fourni automatiquement :
```bash
cp apps/api/.env.test.example apps/api/.env.test
# puis édite-le : mêmes identifiants Postgres que ton .env, mais une base
# différente (ex. batchcooking_test) — .env.test.example documente les
# commandes exactes pour la créer et lui appliquer le schéma.
```
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.
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute quatre jobs indépendants (`lint`, `test`, `build`, `e2e`) 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`).
## Déploiement
@ -190,22 +126,10 @@ client) via `FRONTEND_DIST_DIR` — voir `packages/express-tools/src/express-ser
en dev natif (`pnpm dev:api`), elle reste vide et `pnpm dev:web` continue de servir
le frontend via son propre serveur Vite (HMR), sur un port séparé, comme avant.
Le `CMD` de l'image enchaîne trois étapes, chacune dans son propre processus
`node` : `prisma migrate deploy` (applique les migrations), puis
`node dist/scripts/seed-runtime.js` (seed des données de référence **et**
synchronisation de la table `sources` depuis le registre d'adaptateurs de code
— nécessaire à chaque démarrage : le registre en mémoire peuplé par
`server.ts` ne survit pas au changement de processus, voir
[specs/backend-architecture.md](specs/backend-architecture.md#sources-externes--adaptateur-registre-synchronisation)),
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
@ -242,14 +166,10 @@ Inscription (création de profil + foyer) et connexion, JWT dans un cookie httpO
l'email ou le mot de passe qui soit incorrect
- `POST /auth/logout` — efface le cookie (204)
- `GET /auth/me` — profil courant, nécessite le cookie de session (401 sinon)
- `DELETE /auth/me` — supprime définitivement le compte après re-saisie du mot
de passe (`{ password }`, 401 `INVALID_CREDENTIALS` si incorrect) ; gère le
départ/transfert d'adminship du foyer avant suppression (voir Foyer plus bas)
Mots de passe hachés avec argon2. `UserProfile.tokenVersion` existe pour
invalider les JWT déjà émis (ex. futur changement de mot de passe) mais rien
ne l'incrémente encore — pas de route de changement d'email/mot de passe
aujourd'hui, seulement la suppression de compte.
Mots de passe hachés avec argon2. Le hash est indépendant du foyer : un profil crée
toujours son propre foyer à l'inscription (rejoindre un foyer existant n'est pas
encore implémenté).
> **argon2 : version pinnée à `0.31.2`, pas de `^`.** La version `0.45.1` (dernière au
> moment de l'écriture) segfault au runtime sur au moins une configuration Windows —
@ -264,178 +184,171 @@ Les tests (Mocha) tournent avec un coût argon2 réduit
provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
`prisma migrate deploy` avant les tests.
## Foyer — création, invitation, admin, sources externes (apps/api)
Un foyer (`house`) a un admin (`adminId`) et un code d'invitation à 8
caractères (`inviteCode`, alphabet sans caractères ambigus `0`/`O`/`1`/`I`).
- `GET`/`PATCH /house/current` — foyer courant. `PATCH { name }` ouvert à tout membre.
- `POST /house` — crée un foyer (l'appelant devient admin) ; `POST /house/join
{ inviteCode }` — rejoint un foyer existant. Les deux 409 `ALREADY_HAS_HOUSE`
si le profil a déjà un foyer.
- `POST /house/leave` — quitte le foyer courant. Si le partant était l'admin,
l'adminship passe au membre restant le plus ancien ; si plus personne ne
reste, le foyer est supprimé (un foyer ne peut jamais rester sans admin).
- `DELETE /house/current` — supprime le foyer (403 `NOT_HOUSE_ADMIN` si appelé
par un non-admin). `DELETE /house/members/:id` — retire un membre (admin
seulement, pas de self-retrait par cette route, utiliser `/leave`).
- `GET`/`PATCH /house/current/sources` — quelles sources externes de recettes
(voir plus bas) le foyer voit dans son catalogue — `{ sourceIds: number[] }`,
remplace (pas de fusion), opt-in (aucune source activée par défaut).
Détail complet (génération du code, transfert d'adminship) :
[specs/backend-architecture.md](specs/backend-architecture.md#house--foyer-adminship-code-dinvitation-sources-activées).
> **Les tests automatisés et `pnpm dev:api` partagent la même base Postgres locale.**
> Lancer `pnpm test` **vide `user_profiles`/`house`** (`TRUNCATE ... CASCADE`,
> voir `test-support/reset-db.ts`) — si tu es en train de tester manuellement à la main
> (via le navigateur ou curl) contre le serveur de dev, un run de tests en parallèle
> efface tes données de test sans prévenir. Pas un bug, juste à savoir.
## Planning (apps/api)
- `GET /planning?date=YYYY-MM-DD` — planning de la semaine (lundi→dimanche)
couvrant `date`, pour le foyer de l'utilisateur connecté — `PlanningView |
null` (`null` = pas de foyer, ou aucun planning pour cette semaine, deux cas
normaux confondus, jamais une erreur).
- `POST /planning/items` — ajoute une recette à un créneau :
`{ date, weekDay, meal, recipeId, portions }`. `portions` est saisi
indépendamment du rendement propre de la recette (`Recipe.portions`) — un
créneau peut mettre à l'échelle.
- `DELETE /planning/items/:id` — retire un item du planning.
- `GET /planning/current` — nécessite le cookie de session (401 sinon). Renvoie le
planning du foyer de l'utilisateur connecté qui couvre la date du jour (`Planning`
dont `start_date <= aujourd'hui <= finish_date`), items inclus avec leur recette
résolue en `{ id, name }` — ou `null` s'il n'y en a aucun (foyer sans planning en
cours, ou profil sans foyer). `null` est une réponse **valide** (200), pas une
erreur : aujourd'hui rien ne permet encore de créer un planning (le module « Calcul
batch-cooking », voir [specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md),
reste à construire), donc c'est l'état attendu tant que ce module n'existe pas.
- Type de réponse partagé : `PlanningView` (`packages/shared/src/types/planning.ts`),
consommé tel quel par `apps/web`.
Le planning d'une semaine est créé à la demande (première recette ajoutée),
jamais en avance.
Détail de `AsyncRequestHandler`/`wrapAsyncHandler` (`packages/express-tools`) —
premier endpoint à combiner `requireAuth`/`AuthLocals` avec un handler async, ce qui
a mis au jour une contrainte générique trop stricte, corrigée à la source :
[specs/backend-architecture.md](specs/backend-architecture.md).
## Recettes — catalogue, favoris, import depuis une source externe (apps/api)
## Données de référence — régimes & allergènes (apps/api)
- `GET /recipes?tab=favoris|perso|foyer|publique&search=&suitableForHousehold=&ingredientIds=&dietIds=`
— catalogue filtré par onglet + filtres optionnels. `PERSONAL`/`HOUSE`/`PUBLIC`
(`Recipe.visibility`) contrôlent qui peut **lire** une recette (jamais qui
peut l'éditer, toujours réservé à l'auteur) ; les recettes issues d'une
source externe non activée pour le foyer du viewer sont masquées de tous les
onglets.
- `GET /recipes/:id`, `POST /recipes`, `PATCH /recipes/:id`,
`DELETE /recipes/:id` (409 `RECIPE_IN_USE` si encore référencée par un
planning), `POST`/`DELETE /recipes/:id/favorite`.
- **Import depuis une source externe** (`/sources`) : `GET
/sources/:sourceKey/browse` (parcourir), `GET
/sources/:sourceKey/preview/:externalId` (prévisualiser sans sauvegarder —
ingrédients/unités/techniques déjà résolus contre les catalogues), `POST
/sources/:sourceKey/import/:externalId` (finaliser — même payload qu'une
création manuelle). Un item de source n'est sauvegardé qu'en conséquence de
son ajout au planning (import transparent si tout est résolu) ou d'une revue
manuelle (ingrédients ambigus à choisir à la main) — jamais un bouton
"importer" isolé. Une seule source concrète aujourd'hui : **TheMealDB**
(API officielle, catalogue anglais).
- `GET /reference/diets` — liste des régimes alimentaires (`Diet`, 5 valeurs seedées).
- `GET /reference/allergies` — liste des allergènes sélectionnables, `{ id, name }`
(le nom vient de `Category.name` — la table `allergy` elle-même ne porte pas de
nom, voir `schema.prisma` — chaque allergène = une `Category` + une unique
`Allergy` sous cette catégorie).
Détail complet (adaptateurs, algorithmes de matching ingrédients/techniques,
synchronisation de la table `sources`) :
[specs/backend-architecture.md](specs/backend-architecture.md#sources-externes--adaptateur-registre-synchronisation).
Les deux sont **publics** (pas de `requireAuth`) : ce sont des données de référence,
pas des données de foyer, et le wizard d'inscription doit pouvoir les lire avant
qu'un compte (donc une session) n'existe.
## Données de référence — régimes, allergènes, ingrédients, unités, techniques (apps/api)
Données seedées via `apps/api/prisma/seed.ts` (`pnpm --filter api prisma:seed`, ou
automatiquement après `prisma migrate reset` — config `prisma.seed` dans
`package.json`). La logique réelle (listes + upsert idempotent) vit dans
`src/db/reference-seed-data.ts`, partagée avec `test-support/reset-db.ts` : chaque
test repart d'une base **avec** ces données de référence, pas de tables vides —
nécessaire pour tester `dietId`/`allergyIds` sur de vraies lignes.
- `GET /reference/diets`, `/allergies`, `/ingredients`, `/units`,
`/tech-steps`, `/sources` — tous **publics** (pas de `requireAuth`) : ce sont
des données de référence, pas des données de foyer, et le wizard
d'inscription doit pouvoir les lire avant qu'un compte n'existe.
`Diet.name` et `Category.name` sont `@unique` — ajouté à ce schéma (pas dans le doc
spec d'origine) précisément pour permettre cet upsert idempotent par nom.
Données seedées via `apps/api/src/db/reference-seed-data.ts` (`pnpm --filter
api prisma:seed`, ou automatiquement après `prisma migrate reset`) — jamais
créées/éditées/supprimées via l'API applicative. `key`/`name` sont `@unique`
pour permettre un seed idempotent (`upsert`). Le catalogue d'ingrédients
(400+) est organisé en 7 rayons/sous-catégories façon supermarché français, et
chaque allergène est classé `ALLERGY` (immunitaire) ou `INTOLERANCE`
(Gluten/Sulfites). Détail complet du schéma :
[specs/batch-cooking-modele.md](specs/batch-cooking-modele.md).
Liste des 14 allergènes : ceux du règlement UE 1169/2011 (annexe II) — liste
standard, pas inventée.
## Foyer & profil — régime, allergènes, ingrédients détestés (apps/api)
**Allergies vs intolérances** (retour fonctionnel, pas dans le doc spec d'origine) :
`Category.kind` (`AllergenKind` — `ALLERGY` | `INTOLERANCE`) classe chaque allergène.
Seuls `Gluten` et `Sulfites` sont en `INTOLERANCE` (réaction non-immunitaire
documentée) ; les 12 autres en `ALLERGY` (réaction immunitaire classique). Classifié
par substance, pas par utilisateur — un même foyer ne peut pas déclarer "allergie au
lait" pour un membre et "intolérance au lait" pour un autre ; a suffi pour le besoin
exprimé, à revoir si ça devient un problème réel. `GET /reference/allergies` renvoie
`kind` dans chaque `AllergyView` ; `PATCH /profile/allergies` ne change pas (une
seule liste d'IDs, `kind` ne sert qu'à grouper l'affichage côté client).
Nécessitent tous une session (`requireAuth`) — données propres à
l'utilisateur/au foyer, pas des données de référence.
## Foyer & profil — nom, régime, allergènes (apps/api)
Nécessitent tous une session (`requireAuth`) — contrairement aux endpoints de
référence ci-dessus, ce sont des données propres à l'utilisateur/au foyer.
- `GET`/`PATCH /house/current` — foyer de l'utilisateur connecté. `GET` renvoie
`null` si le profil n'a pas encore de foyer (cas théorique : le signup en crée
toujours un) ; `PATCH { name }` le renomme (`404 HOUSE_NOT_FOUND` si le profil
n'a pas de foyer).
- `PATCH /profile/diet { dietId: number | null }` — régime du profil connecté ;
`null` efface le régime.
- `GET`/`PATCH /profile/allergies` — allergènes/intolérances (medical), liste
d'IDs, remplace (pas de fusion).
- `GET`/`PATCH /profile/disliked-ingredients` — ingrédients personnellement
"pas aimés" (**goût, pas médical** — ne déclenche jamais un avertissement de
sécurité, juste un rappel discret sur la fiche recette), même contrat de
remplacement.
- `GET`/`PATCH /preferences { theme: "LIGHT"|"DARK"|"SYSTEM" }` — préférence
d'affichage, upsert (pas de ligne tant que rien n'a été choisi, défaut
`SYSTEM`).
`null` efface le régime (étape "skippable" du parcours). `404 DIET_NOT_FOUND` si
`dietId` ne correspond à aucun régime de référence.
- `GET`/`PATCH /profile/allergies` — allergènes/intolérances du profil connecté,
sous forme de liste d'IDs (`number[]`). `PATCH { allergyIds }` **remplace**
l'ensemble (pas une fusion — le client renvoie toujours la sélection complète,
cohérent avec un composant de multi-sélection). `404 ALLERGY_NOT_FOUND` si un ID
ne correspond à aucun allergène de référence.
`apps/api/src/lib/safe-profile.ts` centralise le retrait du `passwordHash`
(`toSafeProfile`).
(`toSafeProfile`), auparavant dupliqué dans `auth.service.ts` et
`require-auth.ts``profile.service.ts` le réutilise aussi.
## Page de connexion / inscription (apps/web)
- `src/api/client.ts``ApiClient` (classe, instance unique exportée
`apiClient`) : enveloppe `fetch` vers l'API (`credentials: "include"`, requis
pour que le cookie de session httpOnly parte/revienne — l'API et le front
sont sur des origines différentes). URL configurable via `VITE_API_URL`
(voir `.env.example`).
- `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET
/auth/me` au chargement pour restaurer la session depuis le cookie ;
`deleteAccount()` pour la suppression de compte.
- `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes
de route (react-router-dom) : l'espace connecté exige d'être connecté,
`/login` et `/signup` redirigent vers `/` si on l'est déjà.
- `src/pages/{Login,Signup}Page.tsx` — validation client instantanée via les
- `src/api/client.ts``ApiClient` (classe, instance unique exportée `apiClient`) :
enveloppe `fetch` vers l'API (`credentials: "include"`, requis pour que le cookie
de session httpOnly parte/revienne — l'API et le front sont sur des origines
différentes). URL configurable via `VITE_API_URL` (voir `.env.example`).
- `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET /auth/me` au
chargement pour restaurer la session depuis le cookie.
- `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes de route
(react-router-dom) : `/` exige d'être connecté, `/login` et `/signup` redirigent vers
`/` si on l'est déjà.
- `src/pages/{Login,Signup,Home}Page.tsx` — validation client instantanée via les
schémas zod partagés (`packages/shared`), erreurs API traduites via
`ErrorMessageService` (voir ci-dessous).
Détail de l'organisation complète (dossiers, routing, SCSS/theming) :
[specs/frontend-architecture.md](specs/frontend-architecture.md).
## Sidebar, planning, recettes & sections (apps/web)
## Accueil, sidebar & sections (apps/web)
Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx`
sidebar (nav Planning/Recettes/Liste de courses, sous-menu Paramètres
repliable, menu compte en pied) et `<Outlet />` pour la route active — montée
une seule fois comme route parente de tout l'espace authentifié (`App.tsx`).
Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` — sidebar
(nav Planning/Recettes/Liste de courses/Foyer & profil + nom/déconnexion en pied) et
`<Outlet />` pour la route active — montée une seule fois comme route parente de tout
l'espace authentifié (`App.tsx`), pas dupliquée par page. `src/pages/HomePage.tsx`
(routée sur `/`) affiche le planning de la semaine du foyer (`GET /planning/current`,
voir plus haut) avec ses états chargement/erreur/vide/rempli ; `Recettes` et `Liste de
courses` n'ont pas encore de backend dédié et rendent pour l'instant le même
composant `ComingSoonPage``Foyer & profil` (`src/pages/HouseholdPage.tsx`), lui,
est une vraie page (voir section suivante). Détail complet (pourquoi une seule route
parente, pourquoi un composant stub partagé) :
[specs/frontend-architecture.md](specs/frontend-architecture.md#applayout--sidebar-commune-à-lespace-connecté).
- **`/``PlanningPage`** : grille complète de la semaine (7 jours × 5
repas), navigation par semaine avec mini-calendrier, ajout via
`RecipePickerDialog` (parcourir le catalogue **et** les sources externes,
prévisualiser avant de confirmer, import transparent en un clic si la
recette d'une source n'est pas encore résolue automatiquement, sinon revue
intégrée dans le même dialogue).
- **`/recettes`** (+ `/recettes/:id`, `/recettes/sources/:sourceKey/:externalId`)
`RecipesPage`, vue maître-détail : onglets favoris/perso/foyer/publique
**plus un onglet par source externe activée pour le foyer**, tableau +
panneau de détail (surlignage des techniques détectées avec infobulle, icônes
d'ingrédients génériques, badges régime/allergènes/reproductible).
`/recettes/nouvelle` et `/recettes/:id/modifier` (`RecipeFormPage`) pour la
création/édition manuelle.
- **`/liste-de-courses`** — toujours un stub (`ComingSoonPage`), le module
« Calcul batch-cooking » reste `TODO` (voir
[specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md)).
- **`/parametres/*`** — Compte (identité + suppression), Préférences
(régime/allergies/ingrédients détestés), Foyer (création/invitation,
membres, sources activées), Préférences utilisateur (thème
clair/sombre/système), Crédits (attribution des icônes CC BY 4.0).
## Parcours profil — foyer, régime, allergènes (apps/web)
Détail complet (pourquoi une seule route parente, le flux d'import détaillé,
les composants UI partagés `Dialog`/`Checkbox`/`Radio`/`Tooltip`) :
[specs/frontend-architecture.md](specs/frontend-architecture.md).
- `src/features/profile/``HouseNameField`, `DietSelect`, `AllergySelect` : champs
contrôlés et "dumb" (reçoivent leurs données en props, ne fetchent rien
eux-mêmes), partagés par les deux surfaces ci-dessous. `AllergySelect` utilise une
grille de cases à cocher dans un `<fieldset>`/`<legend>` plutôt qu'un
`<select multiple>` — bien plus repérable/tapable, notamment sur mobile. Prend un
`legend` en prop (pas un libellé fixe interne) : le même composant est rendu
**deux fois** par chaque page consommatrice — une fois pour les allergies
(`AllergyView.kind === "ALLERGY"`), une fois pour les intolérances
(`"INTOLERANCE"`) — les deux listes filtrées côté client à partir d'un seul
`GET /reference/allergies`, mais la sélection (`allergyIds`) reste une seule
liste d'IDs partagée entre les deux groupes (une seule `PATCH /profile/allergies`).
- `src/pages/onboarding/` — wizard de 3 écrans lancé une fois juste après
l'inscription (`OnboardingHouseholdPage` → `OnboardingDietPage`
`OnboardingAllergensPage`, routes `/onboarding/{foyer,regime,allergenes}`).
Chaque étape a un unique bouton "Continuer" qui envoie la valeur courante (y
compris "aucune" pour régime/allergènes) — pas de bouton "Passer" séparé, skip
implicite. Routes top-level `RequireAuth`, **pas** nichées sous `AppLayout` :
wizard plein écran sans sidebar, même langage visuel que `/login`/`/signup`.
- `src/pages/HouseholdPage.tsx` (routée sur `/foyer`) — mêmes réglages, modifiables
à tout moment. **Hot saving** (retour fonctionnel) : pas de bouton "Enregistrer",
chaque section sauvegarde automatiquement peu après la dernière modification —
nom du foyer et allergènes/intolérances debouncés (respectivement 600ms/500ms,
pour ne pas spammer l'API à chaque frappe/case cochée), régime sauvegardé
immédiatement (sélection discrète, pas de saisie continue). Déclenché depuis le
handler `onChange` de chaque champ, jamais depuis un `useEffect` générique qui
observerait la valeur — un tel effect se déclencherait aussi au chargement
initial (quand le `GET` peuple le même state), sans moyen propre de distinguer
"vient d'être chargé" de "vient d'être modifié par l'utilisateur".
## Parcours d'inscription — onboarding (apps/web)
**Piège trouvé en testant dans le navigateur** : `RedirectIfAuthenticated` (garde de
`/login`/`/signup`) réagissait à *chaque* changement de `user`, pas seulement à la
vérification initiale — un `navigate()` explicite dans le gestionnaire de soumission
d'un formulaire qu'elle protège (ex. `SignupPage` après `signup()`, qui met `user` à
jour) entre alors en course avec le propre `<Navigate>` de la garde. Invisible tant
que les deux ciblaient "/", devenu un vrai bug dès que `SignupPage` a dû rediriger
ailleurs (`/onboarding/foyer`). Fix : la décision de redirection est verrouillée une
seule fois, au moment où `isLoading` passe à `false`, plus jamais réévaluée après.
Wizard de 4 écrans lancé une fois juste après l'inscription :
`/onboarding/regime``/onboarding/foyer``/onboarding/sources`
(conditionnelle, sautée si aucun foyer n'a été créé/rejoint à l'étape
précédente) → `/onboarding/allergenes`. Chaque étape a un unique bouton
"Continuer" qui envoie la valeur courante (y compris "aucune" pour
régime/allergènes) — pas de bouton "Passer" séparé, skip implicite. Routes
top-level `RequireAuth`, **pas** nichées sous `AppLayout` : wizard plein écran
sans sidebar, même langage visuel que `/login`/`/signup`. Les mêmes réglages
restent modifiables à tout moment depuis `/parametres/*` (hot saving, pas de
bouton "Enregistrer" — chaque champ sauvegarde peu après la dernière
modification).
**Autre piège, même méthode** : `HouseholdPage` initialisait le régime affiché depuis
`useAuth().user.dietId` (un instantané jamais rafraîchi après une modification faite
directement via `apiClient`, qui ne touche pas `AuthContext`) — revenait à l'ancienne
valeur après un aller-retour de navigation SPA sans rechargement complet. Fix : la
page fetch son propre profil frais (`apiClient.me()`) au montage, et
`AuthContext.refreshUser()` (nouveau) est appelé après une sauvegarde réussie du
régime pour que le reste de l'app reste cohérent aussi.
Tests Cypress (`apps/web/cypress/e2e/*.cy.ts` et `*.feature` +
`@badeball/cypress-cucumber-preprocessor`) : mockent l'API via `cy.intercept`
plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne provisionne
pas de Postgres/API, seulement le serveur de dev Vite. Le comportement réel de
l'API est couvert par la suite Mocha d'`apps/api` (contre une vraie base).
Détail du dispositif de test (Gherkin + steps partagés, tests de composant) :
[specs/frontend-architecture.md](specs/frontend-architecture.md#tests-cypress--cucumber).
Tests Cypress (`apps/web/cypress/e2e/*.cy.ts`) : mockent l'API via `cy.intercept`
plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne provisionne pas de
Postgres/API, seulement le serveur de dev Vite. Le comportement réel de l'API est
couvert par la suite Mocha d'`apps/api` (contre une vraie base).
> **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** :
> Chromium/Electron headless plante au lancement du process GPU
@ -450,9 +363,8 @@ Détail du dispositif de test (Gherkin + steps partagés, tests de composant) :
## Gestion des erreurs (API ↔ web)
Contrat d'erreurs partagé via `packages/shared` (`ErrorCode`, énumération
**numérique** groupée par famille — `4000` validation, `401x` auth, `402x`
conflit/état invalide, `403x` autorisation, `404x` not found, `500x` interne
— et `ApiErrorResponse`) : l'API renvoie toujours
**numérique** groupée par famille — `4000` validation, `401x` auth, `404x` not
found, `500x` interne — et `ApiErrorResponse`) : l'API renvoie toujours
`{ code, message, details? }` (message en anglais, dev-facing — jamais affiché tel
quel), et le client traduit `code` en libellé français via **i18next**
(`ErrorMessageService`, `apps/web/src/services/error-message.service.ts`
@ -462,8 +374,8 @@ centralisent la transformation de toute erreur levée en réponse HTTP conforme
aucune valeur `ErrorCode` codée en dur nulle part (toujours `ErrorCode.XXX`, y
compris dans les mocks Cypress).
Détail complet (schéma, liste des ~19 codes actuels, exemples, comment ajouter
un nouveau code d'erreur) : [specs/error-handling.md](specs/error-handling.md).
Détail complet (schéma, exemples, comment ajouter un nouveau code d'erreur) :
[specs/error-handling.md](specs/error-handling.md).
Le profil authentifié (`requireAuth`) passe par `res.locals.userProfile`
(typé via `AuthLocals`), pas par une augmentation du namespace global Express —
@ -479,21 +391,9 @@ voir [specs/backend-architecture.md](specs/backend-architecture.md#packagesshare
**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,
erreurs) vient de fichiers de locale JSON (`apps/web/src/locales/<lng>/translation.json`),
jamais codé en dur dans un composant. Une seule langue existe aujourd'hui (`fr`) ;
en ajouter une est une question de fichier de locale, pas de code. Les
libellés des tables de référence (régimes, allergènes, ingrédients, unités,
techniques) vivent aussi dans ce fichier (`catalog.*`, par `key` stable de
`schema.prisma`), jamais stockés en base. Détail :
en ajouter une est une question de fichier de locale, pas de code. Détail :
[specs/frontend-architecture.md](specs/frontend-architecture.md#i18n-internationalisation).
## Thème clair / sombre / système (apps/web)
Préférence par utilisateur, persistée côté serveur (`GET`/`PATCH
/preferences`, pas `localStorage`). Tokens de design en custom properties CSS
(`apps/web/src/styles/_theme.scss`) redéfinies sous `[data-theme="dark"]`
(choix explicite) ou sous `prefers-color-scheme: dark` quand aucun
`data-theme` n'est posé (choix "système", le défaut). Détail :
[specs/frontend-architecture.md](specs/frontend-architecture.md#thème-clairsombresystème).
## Données de test (faker.js)
`apps/api` utilise [`@faker-js/faker`](https://fakerjs.dev/) pour toutes les données

View file

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

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

@ -1,6 +0,0 @@
-- DropForeignKey
ALTER TABLE "tech_step_mapping" DROP CONSTRAINT "tech_step_mapping_tech_step_id_fkey";
-- DropTable
DROP TABLE "tech_step_mapping";

View file

@ -1,4 +0,0 @@
-- AlterTable
ALTER TABLE "step_tech_step" ADD COLUMN "context_end" INTEGER,
ADD COLUMN "context_start" INTEGER;

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")
}
@ -521,9 +517,6 @@ model Ingredient {
dislikedBy UserProfileDislikedIngredient[]
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
diets IngredientDiet[]
/// Mentions of this ingredient detected in a step's free text alongside a
/// technique — see `StepTechStepIngredient`.
stepTechSteps StepTechStepIngredient[]
@@map("ingredients")
}
@ -600,12 +593,6 @@ model Unit {
toBaseFactor Decimal @default(1) @map("to_base_factor") @db.Decimal(12, 4)
recipeIngredients RecipeIngredient[]
/// Ingredient mentions detected alongside a technique in a step's free
/// text (e.g. "50g" resolved against this `Unit`) — see
/// `StepTechStepIngredient`. Distinct from `recipeIngredients` above
/// (the recipe's structured ingredient list): a step can mention a
/// quantity+unit that was never itself an ingredient list line.
stepTechStepIngredients StepTechStepIngredient[]
@@map("unit")
}
@ -632,52 +619,34 @@ model RecipeIngredient {
/// camelCase uid (e.g. `"simmer"`), not the display label — the French
/// label lives in `apps/web`'s `locales/fr/translation.json` under
/// `catalog.techSteps.<key>` (see `reference-seed-data.ts`'s `TECH_STEPS`).
///
/// 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.
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[]
mappings TechStepMapping[]
@@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 {
/// Used by `tech-step-matcher.ts` to auto-detect which technique a recipe
/// step's description corresponds to (expression = regex pattern tested
/// against the description, weight = tie-break score when several
/// mappings match, or overlap-resolution score when two mappings match the
/// same span of text — see `matchTechSteps`). `locale` (e.g. `"fr"`) lets
/// the same TechStep carry one matching rule set per language — the
/// matcher is always called with a target locale and only considers
/// mappings for that locale.
model TechStepMapping {
id Int @id @default(autoincrement())
key String @unique
techStepId Int @map("tech_step_id")
locale String
expression String
weight Int
steps StepTechStepUtensil[]
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
@@map("utensil")
@@map("tech_step_mapping")
}
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the
@ -693,9 +662,6 @@ model Step {
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
techSteps StepTechStep[]
/// User-submitted corrections to this step's detected techniques — see
/// `StepTechStepCorrection`.
corrections StepTechStepCorrection[]
@@map("step")
}
@ -710,190 +676,26 @@ model Step {
/// techniques in the description), not a global ordering across different
/// steps of the recipe (that's `Step.order`).
///
/// `start`/`end` are the tight matched *keyword* span within
/// `Step.description` (see `TechStepMatch`, `tech-step-matcher.ts`) — what
/// the recipe detail view highlights strongly, with a tooltip.
/// `contextStart`/`contextEnd` are the wider *clause* the keyword was found
/// in (e.g. "Dans une poêle chaude" around a `preheat` keyword of "poêle
/// chaude") — always contains `start`/`end` — what the detail view
/// highlights more subtly around it, so both "the exact trigger word(s)"
/// and "how much of the sentence is about this technique" are visible.
/// Nullable, **not backfilled**: adding them `NOT NULL` without a default
/// would fail outright against any pre-existing row, the same mistake the
/// `ingredient_unit_catalog` migration made against real prod data. A row
/// from before a column existed just has no span for it (no highlight)
/// until its recipe is next saved, which recomputes every step's
/// `start`/`end` are the matched span within `Step.description` (see
/// `TechStepMatch`, `tech-step-matcher.ts`) — what the recipe detail view
/// highlights. Nullable, **not backfilled**: adding them `NOT NULL` without
/// a default would fail outright against any pre-existing row, the same
/// mistake the `ingredient_unit_catalog` migration made against real prod
/// data. A row from before this column existed just has no span (no
/// highlight) until its recipe is next saved, which recomputes every step's
/// 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")
order Int
start Int?
end Int?
contextStart Int? @map("context_start")
contextEnd Int? @map("context_end")
source String @default("auto")
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
/// Ingredients mentioned in the same clause as this technique occurrence
/// — see `StepTechStepIngredient`.
ingredients StepTechStepIngredient[]
/// Utensils mentioned in the same clause as this technique occurrence —
/// see `StepTechStepUtensil`.
utensils StepTechStepUtensil[]
@@id([stepId, order])
@@map("step_tech_step")
}
/// An ingredient mention found in the same *clause* as one `StepTechStep`
/// occurrence (`tech-step-matcher.ts`'s `matchTechStepSpans` — clauses are
/// already the unit a technique is judged on, see that file's doc comment,
/// so "same clause" is the association rule, no dependency-parsing needed).
/// `quantity`/`unitId` are best-effort, populated only when a leading
/// numeric expression immediately preceding the ingredient mention resolved
/// against the `Unit` catalog (`ingredient-matcher.ts`'s
/// `findIngredientMentions`) — both `null` when the clause names the
/// ingredient with no quantity ("ajouter le sel"). `start`/`end` are the
/// ingredient mention's own span in `Step.description`, same `[start, end)`
/// convention as `StepTechStep.start`/`end`. `source` mirrors
/// `StepTechStep.source` (`"auto"` today, room for a future user
/// correction without a shape change).
model StepTechStepIngredient {
id Int @id @default(autoincrement())
stepId Int @map("step_id")
techStepOrder Int @map("tech_step_order")
ingredientId Int @map("ingredient_id")
quantity Decimal? @db.Decimal(10, 2)
unitId Int? @map("unit_id")
start Int
end Int
source String @default("auto")
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
unit Unit? @relation(fields: [unitId], references: [id])
@@map("step_tech_step_ingredient")
}
/// A utensil mention found in the same clause as one `StepTechStep`
/// occurrence — same association rule as `StepTechStepIngredient` (see its
/// doc comment). Detected by
/// `services/tech-step-intent-service`'s own utensil `PhraseMatcher`
/// (`intent_service/utensil_vocabulary.py`), returned alongside technique
/// entities in `POST /v1/process` and filtered to this clause's span by
/// `tech-step-matcher.ts`.
model StepTechStepUtensil {
id Int @id @default(autoincrement())
stepId Int @map("step_id")
techStepOrder Int @map("tech_step_order")
utensilId Int @map("utensil_id")
start Int
end Int
source String @default("auto")
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
utensil Utensil @relation(fields: [utensilId], references: [id], onDelete: Cascade)
@@map("step_tech_step_utensil")
}
/// One user-submitted correction to a `Step`'s detected techniques —
/// captures ADD (a missing technique the classifier didn't find),
/// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape:
/// `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

@ -1,19 +1,15 @@
import { errorHandlerService } from "@batch-cooking/error-tools";
import { createErrorMiddleware, ExpressServer } from "@batch-cooking/express-tools";
import { ExpressServer, createErrorMiddleware } from "@batch-cooking/express-tools";
import { ErrorCode } from "@batch-cooking/shared";
import type { Express, Request, Response } from "express";
import { env } from "./config/env.js";
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";
/**
@ -26,12 +22,6 @@ import { sourcesRouter } from "./modules/sources/sources.routes.js";
*/
export function createServer(): ExpressServer {
const server = new ExpressServer();
// First middleware registered, before even setupCore's own (CORS/JSON
// body parsing/cookies) — it only reads `req`/`res`, so it doesn't need
// to run after them, and mounting it first means it wraps the *whole*
// pipeline (its "finish" listener still fires for a request that never
// makes it past CORS/body-parsing, not just ones that reach a route).
server.addMiddleware(requestLogger);
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
server.addRoute("get", "/health", (_req: Request, res: Response) => {
@ -40,18 +30,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
@ -69,12 +52,10 @@ export function createServer(): ExpressServer {
res.status(404).json({ code: ErrorCode.NOT_FOUND, message: "Not found" });
});
// Two error-handling middlewares in a row (Express runs them in
// registration order, same as regular middleware) — errorLogger logs the
// error, then hands it on (`next(err)`) to the real one: all the "what
// status/body does this error map to" logic lives in ErrorHandlerService,
// from @batch-cooking/error-tools — this stays a thin adapter.
server.setErrorHandler(errorLogger);
// Final error-handling middleware: every thrown/`next(err)`-ed error in
// the app ends up here. All the "what status/body does this error map
// to" logic lives in ErrorHandlerService, from @batch-cooking/error-tools
// — this stays a thin adapter.
server.setErrorHandler(createErrorMiddleware(errorHandlerService));
return server;

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

@ -1,22 +1,3 @@
// Imported for its side effect only (loading `.env`/`.env.test` via
// dotenv) — must run *before* `new PrismaClient()` below. The generated
// Prisma Client bakes in its own fallback `.env` path (always
// `apps/api/.env`, the dev one — resolved once at `prisma generate` time)
// and loads it internally the first time a `PrismaClient` is constructed,
// unless `DATABASE_URL` is already set in `process.env` by then — dotenv
// never overrides an already-set variable, so whichever of these two env
// loads runs first "wins" for the rest of the process. Without this
// import, that race depended entirely on which test file some *other*
// module happened to import first, which normally worked out only by
// coincidence (whatever file mocha's `test/**/*.test.ts` glob happens to
// resolve first) — running a single test file in isolation (e.g. `mocha
// test/some-file.test.ts` directly, bypassing that glob) could silently
// resolve `DATABASE_URL` to the real dev database instead of
// `.env.test`'s. `resetDatabase()`'s own `assertRunningAgainstTestDatabase`
// guard (test-support/reset-db.ts) is what actually caught this in
// practice — it throws rather than truncating the wrong database — but
// the fix belongs here, at the source, not just at that one call site.
import "../config/env.js";
import { PrismaClient } from "@prisma/client";
/**

View file

@ -1,5 +1,5 @@
import type { PrismaClient } from "@prisma/client";
import { listRecipeSources } from "../lib/recipe-sources/recipe-source-registry.js";
import { listRecipeSources } from "../lib/recipe-source-registry.js";
/**
* Upserts one `Source` row (schema.prisma) per adapter currently in
@ -25,15 +25,10 @@ import { listRecipeSources } from "../lib/recipe-sources/recipe-source-registry.
* themselves at startup).
*/
export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
try {
for (const adapter of listRecipeSources()) {
await prisma.source.upsert({
where: { key: adapter.key },
update: {
name: adapter.name,
official: adapter.official,
iconUrl: adapter.iconUrl,
},
update: { name: adapter.name, official: adapter.official, iconUrl: adapter.iconUrl },
create: {
key: adapter.key,
name: adapter.name,
@ -42,12 +37,6 @@ export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
},
});
}
} catch (err) {
// Rethrown as-is — callers (app startup, `sources.service.ts` via test
// setup) already handle/log failures centrally; this function just
// isn't allowed a bare `await` per the repo's async/try-catch convention.
throw err;
}
}
/**
@ -67,12 +56,9 @@ export async function findImportedRecipeIds(
sourceKey: string,
externalIds: string[],
): Promise<Map<string, number>> {
try {
if (externalIds.length === 0) return new Map();
const source = await prisma.source.findUnique({
where: { key: sourceKey },
});
const source = await prisma.source.findUnique({ where: { key: sourceKey } });
if (!source) return new Map();
const imported = await prisma.recipe.findMany({
@ -84,7 +70,4 @@ export async function findImportedRecipeIds(
recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [],
),
);
} catch (err) {
throw err; // see syncRecipeSources()'s catch comment above
}
}

View file

@ -51,144 +51,245 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }>
{ uid: "pound", type: "MASS", toBaseFactor: 453.5924 },
];
// Cooking-technique catalog (French recipe-step normalization) — the
// stable `key`s `tech-step-matcher.ts` auto-detects in a free-text
// `Step.description` (a step can mention several, e.g. "faire chauffer une
// poêle puis y faire fondre le beurre" is both `preheat` and `melt` — see
// `Step.techSteps`/`StepTechStep` in schema.prisma). Same "English
// camelCase uid, no French label" authoring as DIETS/UNITS — the label
// lives in apps/web's locales/fr/translation.json under
// `catalog.techSteps.<key>`.
//
// 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.
export const TECH_STEPS: string[] = [
"cook",
"fry",
"melt",
"deglaze",
"simmer",
"boil",
"roast",
"grill",
"panFry",
"blanch",
"marinate",
"chop",
"peel",
"mince",
"mix",
"whisk",
"foldIn",
"setAside",
"season",
"drain",
"brown",
"rest",
"preheat",
"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",
// Cooking-technique catalog (French recipe-step normalization) — a static
// list of common instructions, each carrying one or more text-matching
// rules used by `tech-step-matcher.ts` to auto-detect which technique(s) a
// free-text `Step.description` corresponds to (a step can mention several,
// e.g. "faire chauffer une poêle puis y faire fondre le beurre" is both
// `preheat` and `melt` — see `Step.techSteps`/`StepTechStep` in
// schema.prisma). Same "English camelCase uid, no French label" authoring
// as DIETS/UNITS — the label lives in apps/web's
// locales/fr/translation.json under `catalog.techSteps.<key>`.
// `expression` is a regex source matched (case/accent-insensitive, via
// `normalizeText`) against the step description; `weight` breaks ties when
// two *different* techniques' expressions match the same span of text
// (highest weight wins) — see `tech-step-matcher.ts`'s `matchTechSteps`.
// Specific, multi-word phrases ("cuire au four", "faire revenir") are
// weighted higher than the generic single-verb forms they overlap with
// ("cuire", "sauter") so the more specific technique wins when both match
// the same words. `locale` lets the same technique carry one matching rule
// set per language — `"fr"` and `"en"` today (the latter mainly for
// English-language sources like TheMealDB), more can be added later
// without a schema change. The two locales are independent rule sets, not
// translations of each other — an English recipe is matched only against
// the `"en"` mappings, never a mix of both.
export const TECH_STEPS: Array<{
uid: string;
mappings: Array<{ locale: string; expression: string; weight: number }>;
}> = [
{
uid: "cook",
mappings: [
{ locale: "fr", expression: "\\bcui(re|sez|sant|sson)\\b|\\bcuit(e|es|s)?\\b", weight: 10 },
{ locale: "en", expression: "\\bcook(s|ed|ing)?\\b", weight: 10 },
],
},
{
uid: "fry",
mappings: [
{ locale: "fr", expression: "\\bfri(re|t|te|ts|tes|ture)\\b", weight: 15 },
{ locale: "en", expression: "\\bfr(y|ies|ied|ying)\\b", weight: 15 },
],
},
{
uid: "melt",
mappings: [
{
locale: "fr",
expression:
"\\bfondre\\b|\\bfondu(e|es|s)?\\b|\\bfaire fondre\\b|\\bfaites fondre\\b|\\bfaire chauffer\\b|\\bfaites chauffer\\b",
weight: 15,
},
{ locale: "en", expression: "\\bmelt(s|ed|ing)?\\b", weight: 15 },
],
},
{
uid: "deglaze",
mappings: [
{ locale: "fr", expression: "\\bd[ée]glac(er|ez|é|ée|age)\\b", weight: 20 },
{ locale: "en", expression: "\\bdeglaz(e|es|ed|ing)\\b", weight: 20 },
],
},
{
uid: "simmer",
mappings: [
{ locale: "fr", expression: "\\bmijot(er|ez|e|ant|é)\\b", weight: 15 },
{ locale: "en", expression: "\\bsimmer(s|ed|ing)?\\b", weight: 15 },
],
},
{
uid: "boil",
mappings: [
{ locale: "fr", expression: "\\bbouill(ir|ant|ie|ies)\\b|\\b[ée]bullition\\b", weight: 12 },
{ locale: "en", expression: "\\bboil(s|ed|ing)?\\b", weight: 12 },
],
},
{
uid: "roast",
mappings: [
{ locale: "fr", expression: "\\br[ôo]tir\\b|\\br[ôo]ti(e|es|s)?\\b", weight: 15 },
{ locale: "en", expression: "\\broast(s|ed|ing)?\\b", weight: 15 },
],
},
{
uid: "grill",
mappings: [
{ locale: "fr", expression: "\\bgrill(er|ez|é|ée|ées|ade)\\b", weight: 15 },
{ locale: "en", expression: "\\bgrill(s|ed|ing)?\\b", weight: 15 },
],
},
{
uid: "panFry",
mappings: [
{ locale: "fr", expression: "\\bsaut(er|ez|é|ée|ées|ant)\\b", weight: 12 },
{
locale: "en",
expression: "\\bsaut[ée](s|ed|ing)?\\b|\\bpan[- ]?fr(y|ies|ied|ying)\\b",
weight: 12,
},
],
},
{
uid: "blanch",
mappings: [
{ locale: "fr", expression: "\\bblanch(ir|issez|i|ie|ies|iment)\\b", weight: 18 },
{ locale: "en", expression: "\\bblanch(es|ed|ing)?\\b", weight: 18 },
],
},
{
uid: "marinate",
mappings: [
{ locale: "fr", expression: "\\bmarin(er|ez|é|ée|ées|ade)\\b", weight: 18 },
{ locale: "en", expression: "\\bmarinat(e|es|ed|ing)\\b|\\bmarinad(e|es)\\b", weight: 18 },
],
},
{
uid: "chop",
mappings: [
{ locale: "fr", expression: "\\bhach(er|ez|é|ée|ées|is)\\b", weight: 15 },
{ locale: "en", expression: "\\bchop(s|ped|ping)?\\b", weight: 15 },
],
},
{
uid: "peel",
mappings: [
{ locale: "fr", expression: "\\b[ée]pluch(er|ez|é|ée|ées|age)\\b", weight: 15 },
{ locale: "en", expression: "\\bpeel(s|ed|ing)?\\b", weight: 15 },
],
},
{
uid: "mince",
mappings: [
{ locale: "fr", expression: "\\b[ée]minc(er|ez|é|ée|ées)\\b", weight: 18 },
{ locale: "en", expression: "\\bminc(e|es|ed|ing)\\b", weight: 18 },
],
},
{
uid: "mix",
mappings: [
{ locale: "fr", expression: "\\bm[ée]lang(er|ez|é|ée|ées|e|es)\\b", weight: 10 },
{ locale: "en", expression: "\\bmix(es|ed|ing)?\\b|\\bcombine(s|d)?\\b", weight: 10 },
],
},
{
uid: "whisk",
mappings: [
{ locale: "fr", expression: "\\bfouett(er|ez|é|ée|ées)\\b|\\bau fouet\\b", weight: 15 },
{ locale: "en", expression: "\\bwhisk(s|ed|ing)?\\b", weight: 15 },
],
},
{
uid: "foldIn",
mappings: [
{ locale: "fr", expression: "\\bincorpor(er|ez|é|ée|ées|ant)\\b", weight: 15 },
{ locale: "en", expression: "\\bfold(s|ed|ing)? in\\b", weight: 15 },
],
},
{
uid: "setAside",
mappings: [
{ locale: "fr", expression: "\\br[ée]serv(er|ez|é|ée|ées)\\b", weight: 15 },
{ locale: "en", expression: "\\bset(s)? aside\\b|\\bsetting aside\\b", weight: 15 },
],
},
{
uid: "season",
mappings: [
{ locale: "fr", expression: "\\bassaisonn(er|ez|é|ée|ées|ement)\\b", weight: 15 },
{ locale: "en", expression: "\\bseason(s|ed|ing)?\\b", weight: 15 },
],
},
{
uid: "drain",
mappings: [
{ locale: "fr", expression: "\\b[ée]goutt(er|ez|é|ée|ées)\\b", weight: 15 },
{ locale: "en", expression: "\\bdrain(s|ed|ing)?\\b", weight: 15 },
],
},
{
uid: "brown",
mappings: [
{
locale: "fr",
expression:
"\\bfaire revenir\\b|\\bfaites revenir\\b|\\bfais revenir\\b|\\bfaire dorer\\b|\\bfaites dorer\\b",
weight: 25,
},
// Verb forms only (not bare "brown"), which would false-positive on
// ingredient descriptions like "brown sugar"/"brown rice".
{ locale: "en", expression: "\\bbrown(ed|ing)\\b", weight: 25 },
],
},
{
uid: "rest",
mappings: [
{ locale: "fr", expression: "\\blaiss(er|ez|e) reposer\\b|\\breposer\\b", weight: 20 },
// Anchored to "let ... rest"/"rest for" rather than bare "rest",
// which would false-positive on phrases like "the rest of the".
{
locale: "en",
expression: "\\blet (it |them )?rest\\b|\\brest(s|ed|ing)? for\\b",
weight: 20,
},
],
},
{
uid: "preheat",
mappings: [
{ locale: "fr", expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b", weight: 20 },
{ locale: "en", expression: "\\bpreheat(s|ed|ing)?\\b", weight: 20 },
],
},
{
uid: "bake",
mappings: [
{
locale: "fr",
expression:
"\\bcuire au four\\b|\\bcuisson au four\\b|\\benfourn(er|ez|é|ée|ées)\\b|\\bau four\\b",
weight: 25,
},
{
locale: "en",
expression: "\\bbak(e|es|ed|ing)\\b|\\bin (a|the) (preheated )?oven\\b",
weight: 25,
},
],
},
{
uid: "plate",
mappings: [
{ locale: "fr", expression: "\\bdress(er|ez|age)\\b", weight: 15 },
{ locale: "en", expression: "\\bplat(e|es|ed|ing)\\b", weight: 15 },
],
},
{
uid: "coat",
mappings: [
{ locale: "fr", expression: "\\bnapp(er|ez|é|ée|ées|age)\\b", weight: 15 },
{ locale: "en", expression: "\\bcoat(s|ed|ing)?\\b", weight: 15 },
],
},
];
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
@ -450,13 +551,6 @@ export const INGREDIENT_GROUPS: Array<{
{ uid: "vealCutlet", allergenUids: [] },
{ uid: "porkTenderloin", allergenUids: [] },
{ uid: "porkChop", allergenUids: [] },
// Ground/minced meats, requested alongside `groundBeef` (already
// seeded) — one per red-meat type the catalog otherwise only offers
// as a whole cut. Ground poultry (turkey/chicken) lives in the
// `poultry` subcategory below, next to their whole-cut counterparts.
{ uid: "groundVeal", allergenUids: [] },
{ uid: "groundPork", allergenUids: [] },
{ uid: "groundLamb", allergenUids: [] },
{ uid: "lamb", allergenUids: [] },
{ uid: "legOfLamb", allergenUids: [] },
{ uid: "baconLardons", allergenUids: [] },
@ -507,9 +601,7 @@ export const INGREDIENT_GROUPS: Array<{
defaultIcon: "POULTRY",
items: [
{ uid: "chicken", allergenUids: [] },
{ uid: "groundChicken", allergenUids: [] },
{ uid: "turkey", allergenUids: [] },
{ uid: "groundTurkey", allergenUids: [] },
{ uid: "duck", allergenUids: [] },
{ uid: "duckBreast", allergenUids: [] },
// Ciqual 2025 additions — see the VIANDES group above.
@ -845,11 +937,7 @@ export const INGREDIENT_GROUPS: Array<{
subcategory: "eggs",
defaultDiets: ["vegetarian", "pescatarian"],
defaultIcon: "EGG",
items: [
{ uid: "egg", allergenUids: ["eggs"] },
{ uid: "eggYolk", allergenUids: ["eggs"] },
{ uid: "eggWhite", allergenUids: ["eggs"] },
],
items: [{ uid: "egg", allergenUids: ["eggs"] }],
},
{
category: "dairyAndCheese",
@ -909,7 +997,6 @@ export const INGREDIENT_GROUPS: Array<{
{ uid: "fiveSpice", allergenUids: [] },
{ uid: "garamMasala", allergenUids: [] },
{ uid: "corianderSeeds", allergenUids: [] },
{ uid: "groundCoriander", allergenUids: [] },
{ uid: "cardamom", allergenUids: [] },
{ uid: "fenugreek", allergenUids: [] },
{ uid: "jalapeno", allergenUids: [] },
@ -1288,16 +1375,36 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
}
// TechStep: upsert by key (same idempotent-seed reasoning as everything
// above) — just the stable id/key rows themselves now, no matching data
// to replace alongside them (see `TECH_STEPS`' own comment for why).
for (const key of TECH_STEPS) {
// above), then fully replace its mappings on every reseed. Mappings carry
// no natural per-row identity to upsert against, and expressions/weights
// are expected to be tuned over time — a straight "delete all, recreate
// from source" keeps the table an exact mirror of `TECH_STEPS` rather
// than accumulating stale/duplicate rows from earlier edits. Nothing else
// references `TechStepMapping.id` (`Step` only points at `TechStep`, not
// at a specific mapping), so this replace is safe.
for (const { uid: key } of TECH_STEPS) {
await prisma.techStep.upsert({ where: { key }, update: {}, create: { key } });
}
const techSteps = await prisma.techStep.findMany({
where: { key: { in: TECH_STEPS.map((t) => t.uid) } },
});
const techStepIdByKey = new Map(techSteps.map((t) => [t.key, t.id]));
// 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 } });
await prisma.techStepMapping.deleteMany({
where: { techStepId: { in: [...techStepIdByKey.values()] } },
});
const techStepMappingRows = TECH_STEPS.flatMap(({ uid, mappings }) => {
const techStepId = techStepIdByKey.get(uid);
if (techStepId === undefined) return [];
return mappings.map(({ locale, expression, weight }) => ({
techStepId,
locale,
expression,
weight,
}));
});
if (techStepMappingRows.length > 0) {
await prisma.techStepMapping.createMany({ data: techStepMappingRows });
}
// `Allergy` itself carries no `key` — it's the selectable instance of a

View file

@ -0,0 +1,191 @@
import { INGREDIENT_LABELS_EN, UNIT_LABELS_EN } 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 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 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
* ingredient text's tokens. The longest (most specific) matching catalog
* phrase wins, same "specificity resolves overlaps" idea as
* `matchTechSteps`, just without weights (there's no need to rank two
* *unrelated* ingredients only a phrase against its own substrings, e.g.
* "chicken breast" beating bare "chicken").
*
* `matchIngredientName`/`matchUnit`/`extractQuantity` are pure, so they're
* unit-testable without a database (see `test/ingredient-matcher.test.ts`);
* `loadIngredientCatalog`/`loadUnitCatalog` are the only DB-touching pieces,
* meant to be fetched once per request and reused across every ingredient
* line, the same "don't requery per item" convention as
* `loadTechStepMappingRules`.
*/
/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its English matching label. */
export interface IngredientMatchEntry {
ingredientId: number;
/** 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 English spellings. */
export interface UnitMatchEntry {
unitId: number;
/** Accepted spellings from `UNIT_LABELS_EN`, e.g. `["tbsp", "tbs", "tablespoon", "tablespoons"]`. */
synonyms: string[];
}
/**
* Naive English stemmer strips a plural suffix so "tomato"/"tomatoes",
* "onion"/"onions", "cherry"/"cherries" compare equal after stemming.
* Deliberately not a real linguistic stemmer: it only has to be
* *consistent* (the same word always stems the same way) since both sides
* of every comparison go through it, not linguistically correct on its
* own see the module doc comment.
*/
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;
}
/** 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(stemWord);
}
/** Whether `needle` appears as a contiguous run inside `haystack`, at any starting position. */
function containsSubsequence(haystack: string[], needle: string[]): boolean {
if (needle.length === 0 || needle.length > haystack.length) return false;
for (let start = 0; start <= haystack.length - needle.length; start++) {
if (needle.every((word, i) => haystack[start + i] === word)) return true;
}
return false;
}
/**
* 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`, 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[]): 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);
if (!containsSubsequence(nameTokens, labelTokens)) continue;
if (
best === null ||
labelTokens.length > best.tokenCount ||
(labelTokens.length === best.tokenCount && entry.ingredientId < best.ingredientId)
) {
best = { ingredientId: entry.ingredientId, tokenCount: labelTokens.length };
}
}
return best?.ingredientId ?? null;
}
/**
* 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[]): number | null {
const tokens = tokenize(unitText);
if (tokens.length === 0) return null;
const firstToken = tokens[0];
for (const entry of catalog) {
if (entry.synonyms.some((synonym) => stemWord(normalizeText(synonym)) === firstToken)) {
return entry.unitId;
}
}
return null;
}
/** What {@link extractQuantity} pulls out of a leading numeric expression, alongside what's left of the string after it. */
export interface ExtractedQuantity {
quantity: number | null;
/** `rawText` with the leading quantity (and any separating whitespace) removed — `rawText` unchanged if none was found. */
remainder: string;
}
// 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.
const LEADING_QUANTITY_PATTERN = /^(\d+)\s+(\d+)\/(\d+)|^(\d+)\/(\d+)|^(\d+(?:[.,]\d+)?)/;
/**
* Pulls a leading quantity off `rawText` (e.g. `"1 1/2 cups flour"` ->
* `{ quantity: 1.5, remainder: "cups flour" }`), supporting a plain
* integer/decimal, a simple fraction (`"1/2"`), or a mixed number
* (`"1 1/2"`). `quantity: null` (remainder === rawText, trimmed) when
* `rawText` doesn't start with a recognizable number e.g. `"salt to
* taste"`, which has none.
*/
export function extractQuantity(rawText: string): ExtractedQuantity {
const trimmed = rawText.trim();
const match = LEADING_QUANTITY_PATTERN.exec(trimmed);
if (!match) return { quantity: null, remainder: trimmed };
let quantity: number;
if (match[1] !== undefined) {
// Mixed number: "1 1/2".
quantity = Number(match[1]) + Number(match[2]) / Number(match[3]);
} else if (match[4] !== undefined) {
// Simple fraction: "1/2".
quantity = Number(match[4]) / Number(match[5]);
} else {
// Plain integer/decimal: "2" or "1.5"/"1,5" — group 6 is guaranteed
// defined here (the only remaining alternative in the pattern), `?? ""`
// is just satisfying the indexed-access type, not a real fallback.
quantity = Number((match[6] ?? "").replace(",", "."));
}
return { quantity, remainder: trimmed.slice(match[0].length).trim() };
}
/** Loads the full `Ingredient` catalog as {@link IngredientMatchEntry}s — one entry per key with an authored English label (see `INGREDIENT_LABELS_EN`); 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[]> {
const ingredients = await prisma.ingredient.findMany({ select: { id: true, key: true } });
const catalog: IngredientMatchEntry[] = [];
for (const ingredient of ingredients) {
const label = INGREDIENT_LABELS_EN[ingredient.key];
if (label !== undefined) catalog.push({ ingredientId: ingredient.id, label });
}
return catalog;
}
/** 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[]> {
const units = await prisma.unit.findMany({ select: { id: true, key: true } });
const catalog: UnitMatchEntry[] = [];
for (const unit of units) {
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";
/**
* Server-side operational logging the one place in this codebase allowed
* to call `console.*` directly (see `biome.json`'s `noConsole`, which bans
* bare `console.log` everywhere else: every log line here goes through a
* named level instead, `logger.info(...)`/`logger.error(...)`, never an
* unlabeled dump of text). Everything else (the request logger, `server.ts`'s
* startup line, the error middleware) goes through this instead of touching
* `console` itself, so there's exactly one place that decides the log
* *shape* (structured JSON lines one object per line, trivially
* grep/parse-able by `docker logs`/Portainer or any log aggregator, unlike
* free-form `console.log` text).
*
* A class, not a plain object literal same convention as
* `ErrorHandlerService` (`packages/error-tools`): `public` methods are the
* actual API, `private` ones are internals a caller never touches directly.
* `export const logger = new LoggerService()` below is the single shared
* instance every caller imports there's exactly one log stream for the
* whole process, nothing to parameterize per call site, so nobody
* constructs their own.
*/
/** Ascending severity — mirrors the standard `debug < info < warn < error` convention. */
export type LogLevel = "debug" | "info" | "warn" | "error";
/** Arbitrary structured context attached to a log line (a request id, a duration, an error's own fields, …) — merged into the emitted JSON object, never interpolated into the message string itself. */
export type LogMeta = Record<string, unknown>;
const LEVEL_SEVERITY: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
/**
* Lines below this level are dropped rather than emitted keeps `debug`
* out of production (verbose, meant for local troubleshooting only) and
* out of the test suite's own output (Mocha's reporter is noisy enough
* already), while `pnpm dev:api` sees everything. A free function, not a
* method exported (unlike the rest of this module's internals) purely so
* `logger.service.test.ts` can exercise the three `NODE_ENV` cases
* directly, without needing a `LoggerService` instance at all.
*/
export function minLevelFor(nodeEnv: typeof env.NODE_ENV): LogLevel {
if (nodeEnv === "production") return "info";
if (nodeEnv === "test") return "warn";
return "debug";
}
/** Console method each level writes through — `error`/`warn` go to stderr (their own native behavior), `debug`/`info` to stdout, the usual split log aggregators expect. */
const CONSOLE_METHOD: Record<LogLevel, "debug" | "info" | "warn" | "error"> = {
debug: "debug",
info: "info",
warn: "warn",
error: "error",
};
/** Server-side operational logger — see the module doc comment for why this exists and what it's for. */
export class LoggerService {
/** Computed once at construction from `env.NODE_ENV` — see {@link minLevelFor}. */
private readonly _minSeverity: number;
public constructor(nodeEnv: typeof env.NODE_ENV = env.NODE_ENV) {
this._minSeverity = LEVEL_SEVERITY[minLevelFor(nodeEnv)];
}
public debug(message: string, meta?: LogMeta): void {
this._emit("debug", message, meta);
}
public info(message: string, meta?: LogMeta): void {
this._emit("info", message, meta);
}
public warn(message: string, meta?: LogMeta): void {
this._emit("warn", message, meta);
}
public error(message: string, meta?: LogMeta): void {
this._emit("error", message, meta);
}
private _emit(level: LogLevel, message: string, meta?: LogMeta): void {
if (LEVEL_SEVERITY[level] < this._minSeverity) return;
// `meta` spread first so a caller accidentally passing e.g. `{ message:
// ... }` in it can never shadow the line's own core fields.
const line = {
...meta,
timestamp: new Date().toISOString(),
level,
message,
};
// biome-ignore lint/suspicious/noConsole: this is the one place allowed to — see the class doc comment above.
console[CONSOLE_METHOD[level]](JSON.stringify(line));
}
}
/** Single shared instance — this service has no per-call-site state to isolate, same reasoning as `errorHandlerService`. */
export const logger = new LoggerService();

View file

@ -1,466 +0,0 @@
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.
*
* 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
* 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
* ingredient text's tokens. The longest (most specific) matching catalog
* phrase wins, same "specificity resolves overlaps" idea as
* `matchTechSteps`, just without weights (there's no need to rank two
* *unrelated* ingredients only a phrase against its own substrings, e.g.
* "chicken breast" beating bare "chicken").
*
* `matchIngredientName`/`matchUnit`/`extractQuantity` are pure, so they're
* unit-testable without a database (see `test/ingredient-matcher.test.ts`);
* `loadIngredientCatalog`/`loadUnitCatalog` are the only DB-touching pieces,
* meant to be fetched once per request and reused across every ingredient
* line, the same "don't requery per item" convention
* `tech-step-matcher.ts`'s `TechStepClassifierService` follows for its own
* one-time training pass.
*/
/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its matching label in whichever locale it was loaded for. */
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. */
label: string;
}
/** One `Unit` row trimmed to what {@link matchUnit} needs, alongside its accepted spellings in whichever locale it was loaded for. */
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. */
synonyms: string[];
}
/**
* Naive English stemmer strips a plural suffix so "tomato"/"tomatoes",
* "onion"/"onions", "cherry"/"cherries" compare equal after stemming.
* Deliberately not a real linguistic stemmer: it only has to be
* *consistent* (the same word always stems the same way) since both sides
* of every comparison go through it, not linguistically correct on its
* own see the module doc comment.
*/
function stemWordEn(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[] {
return normalizeText(text)
.split(/[^a-z]+/)
.filter((word) => word.length > 0)
.map((word) => stemWord(word, locale));
}
/** Whether `needle` appears as a contiguous run inside `haystack`, at any starting position. */
function containsSubsequence(haystack: string[], needle: string[]): boolean {
if (needle.length === 0 || needle.length > haystack.length) return false;
for (let start = 0; start <= haystack.length - needle.length; start++) {
if (needle.every((word, i) => haystack[start + i] === word)) return true;
}
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"`.
*/
export function matchIngredientName(
name: string,
catalog: IngredientMatchEntry[],
locale = "en",
): number | null {
const nameTokens = tokenize(name, locale);
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);
if (!containsSubsequence(nameTokens, labelTokens)) continue;
if (
best === null ||
labelTokens.length > best.tokenCount ||
(labelTokens.length === best.tokenCount && entry.ingredientId < best.ingredientId)
) {
best = {
ingredientId: entry.ingredientId,
tokenCount: labelTokens.length,
};
}
}
return best?.ingredientId ?? null;
}
/**
* 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"`.
*/
export function matchUnit(
unitText: string,
catalog: UnitMatchEntry[],
locale = "en",
): number | null {
const textTokens = tokenize(unitText, locale);
if (textTokens.length === 0) return null;
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 };
}
}
}
return best?.unitId ?? null;
}
/** What {@link extractQuantity} pulls out of a leading numeric expression, alongside what's left of the string after it. */
export interface ExtractedQuantity {
quantity: number | null;
/** `rawText` with the leading quantity (and any separating whitespace) removed — `rawText` unchanged if none was found. */
remainder: string;
}
// 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.
const LEADING_QUANTITY_PATTERN = /^(\d+)\s+(\d+)\/(\d+)|^(\d+)\/(\d+)|^(\d+(?:[.,]\d+)?)/;
/**
* Pulls a leading quantity off `rawText` (e.g. `"1 1/2 cups flour"` ->
* `{ quantity: 1.5, remainder: "cups flour" }`), supporting a plain
* integer/decimal, a simple fraction (`"1/2"`), or a mixed number
* (`"1 1/2"`). `quantity: null` (remainder === rawText, trimmed) when
* `rawText` doesn't start with a recognizable number e.g. `"salt to
* taste"`, which has none.
*/
export function extractQuantity(rawText: string): ExtractedQuantity {
const trimmed = rawText.trim();
const match = LEADING_QUANTITY_PATTERN.exec(trimmed);
if (!match) return { quantity: null, remainder: trimmed };
let quantity: number;
if (match[1] !== undefined) {
// Mixed number: "1 1/2".
quantity = Number(match[1]) + Number(match[2]) / Number(match[3]);
} else if (match[4] !== undefined) {
// Simple fraction: "1/2".
quantity = Number(match[4]) / Number(match[5]);
} else {
// Plain integer/decimal: "2" or "1.5"/"1,5" — group 6 is guaranteed
// defined here (the only remaining alternative in the pattern), `?? ""`
// is just satisfying the indexed-access type, not a real fallback.
quantity = Number((match[6] ?? "").replace(",", "."));
}
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[]> {
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];
if (label === undefined) continue;
catalog.push({ ingredientId: ingredient.id, label });
for (const synonym of synonyms[ingredient.key] ?? []) {
catalog.push({ ingredientId: ingredient.id, label: synonym });
}
}
return catalog;
} catch (err) {
// Rethrown as-is — the caller (`sources.service.ts`/`recipe-translation.ts`)
// already handles/logs failures centrally; this function just isn't
// allowed a bare `await` per the repo's async/try-catch convention.
throw err;
}
}
/** 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[]> {
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];
if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms });
}
return catalog;
} catch (err) {
throw err; // see loadIngredientCatalog()'s catch comment above
}
}

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

@ -1,331 +0,0 @@
import type { UnitType } from "@batch-cooking/shared";
import type {
ParsedRecipe,
ParsedRecipeIngredient,
ParsedRecipeStep,
} from "../recipe-sources/recipe-source-adapter.js";
import {
extractQuantity,
type IngredientMatchEntry,
loadIngredientCatalog,
loadUnitCatalog,
matchIngredientName,
matchUnit,
type UnitMatchEntry,
} from "./ingredient-matcher.js";
import { techStepClassifier } from "./tech-step-matcher.js";
/**
* The "Traduction en étapes" stage of the import pipeline described in
* specs/batch-cooking-architecture.md (Import depuis source **Traduction
* en étapes** Sauvegarde) takes a source-agnostic {@link ParsedRecipe}
* (recipe-source-adapter.ts's `parse()` output) and declares each step's
* technique sequence, the same `techStepIds: number[]` shape
* `Step.techSteps`/`StepTechStep` (schema.prisma) will eventually persist.
*
* Also resolves ingredients matching each free-text `ParsedRecipeIngredient`
* line against our `Ingredient`/`Unit` catalogs (`ingredient-matcher.ts`),
* the same "free source text -> our catalog id" idea as tech-step
* detection, just for ingredients/units/quantities instead of technique
* verbs. Like tech-step matching, this doesn't turn the result into a
* saveable `Recipe` either (no `dietIds`/`visibility`/author, a source
* can't know those) this is one step of the pipeline, not the whole
* thing.
*
* `translateRecipeIngredients` stays pure (takes its matching data as plain
* arguments, same convention `matchIngredientName` itself has) so it's
* unit-testable without a database. `translateRecipeSteps` no longer is
* technique detection now goes through `techStepClassifier`'s trained
* model (`tech-step-matcher.ts`), which needs an async call but is still
* exported separately from `translateRecipe` for callers/tests that only
* care about step translation, not ingredients too.
*/
/** A {@link ParsedRecipeStep}, after tech-step detection — declares its technique sequence alongside the description/picture it already had. */
export interface TranslatedRecipeStep extends ParsedRecipeStep {
/** Ordered sequence of detected `TechStep` ids (see `TechStepClassifierService.matchTechSteps`) — empty if this step doesn't mention any known technique. */
techStepIds: number[];
}
/** A {@link ParsedRecipeIngredient}, after ingredient/unit matching — `quantity` is filled in from `rawText` when the source itself left it `null` (see `extractQuantity`); `ingredientId`/`unitId` are `null` when nothing in the catalog matched. */
export interface TranslatedRecipeIngredient extends ParsedRecipeIngredient {
ingredientId: number | null;
unitId: number | null;
}
/** A {@link ParsedRecipe} whose `steps`/`ingredients` have been translated — everything else (name, portions, …) passes through unchanged. */
export interface TranslatedRecipe extends Omit<ParsedRecipe, "steps" | "ingredients"> {
steps: TranslatedRecipeStep[];
ingredients: TranslatedRecipeIngredient[];
}
/**
* Declares each of `recipe`'s steps' technique sequence for `locale`,
* leaving everything else about the recipe untouched including
* ingredients, which are only stubbed to `TranslatedRecipeIngredient`'s
* shape here (`ingredientId`/`unitId: null`, `quantity`/`unit` untouched);
* actually resolving them is {@link translateRecipeIngredients}'s job, kept
* separate the same way tech-step and ingredient matching are two
* independent concerns everywhere else in this module. Async technique
* detection now runs against `techStepClassifier`'s trained model rather
* than a caller-supplied mapping list (see `tech-step-matcher.ts`), so this
* can no longer stay a plain synchronous function the way it used to.
*/
export async function translateRecipeSteps(
recipe: ParsedRecipe,
locale: string,
): Promise<TranslatedRecipe> {
try {
const steps = await Promise.all(
recipe.steps.map(async (step) => ({
...step,
techStepIds: await techStepClassifier.matchTechSteps(step.description, locale),
})),
);
return {
...recipe,
ingredients: recipe.ingredients.map((ingredient) => ({
...ingredient,
ingredientId: null,
unitId: null,
})),
steps,
};
} catch (err) {
// Rethrown as-is — the caller (`sources.service.ts`) already
// handles/logs failures centrally; this function just isn't allowed a
// bare `await` per the repo's async/try-catch convention.
throw err;
}
}
/**
* 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.
*/
const FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE: Record<string, string> = {
en: "piece",
fr: "unité",
};
/**
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
* against `ingredientCatalog`/`unitCatalog` (see `ingredient-matcher.ts`).
* Pure testable with hand-built catalogs, no database involved (see
* `translateRecipe` for the DB-backed loader). `quantity` falls back to
* `extractQuantity(rawText)` only when the source itself left it `null`;
* same for `unit` falling back to `extractQuantity`'s `remainder` before
* being matched against `unitCatalog` a source that already states a
* clean unit/quantity is trusted over re-deriving it from `rawText`.
*
* When a quantity was found but nothing in the remaining text matched a
* unit (e.g. `"4 Egg Yolks"` quantity `4`, remainder `"Egg Yolks"`, no
* unit word anywhere in it), `unitId` falls back to the catalog's generic
* `piece` unit rather than staying `null`: a bare count with no explicit
* unit word is overwhelmingly "N of them" (eggs, onions, cloves not
* spelled out as "clove") in practice, not a genuinely missing unit see
* issue #53, where this previously left the import review form's submit
* 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 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);
return { ...ingredient, quantity, ingredientId, unitId };
});
}
/** What {@link mergeDuplicateIngredients} needs to know about a `Unit` to combine two of them — the `type`/`toBaseFactor` slice of `UnitView` (`packages/shared`). */
export interface UnitConversionEntry {
id: number;
type: UnitType;
toBaseFactor: number;
}
/**
* Combines `a`/`b` two lines already confirmed to resolve to the same
* ingredient into one, summing their quantities, or returns `null` when
* that can't be done safely. `null` (quantity or unit missing on either
* side, unit not found in `unitById`, mismatched `UnitType`, or either side
* a `COUNT` unit) means "don't merge", not "error" see
* {@link mergeDuplicateIngredients}.
*
* Same unit on both sides sums directly. Different units of the same
* measurable *type* (`MASS`/`VOLUME`) convert `b`'s quantity into `a`'s
* unit via `toBaseFactor` first the groundwork that field's own doc
* comment (`UnitView`, `packages/shared`) already anticipated ("a future
* conversion feature ... summing '500g' + '0.5kg'"). `COUNT` units are
* never converted against each other even when `toBaseFactor` matches a
* "pincée" isn't a fixed fraction of a "gousse" (same doc comment) so two
* different `COUNT` units for the same ingredient are left unmerged.
* Rounded to 2 decimal places (`RecipeIngredient.quantity` is
* `Decimal(10, 2)`, schema.prisma) to avoid floating-point noise from the
* conversion.
*/
function combineIngredientLines(
a: TranslatedRecipeIngredient,
b: TranslatedRecipeIngredient,
unitById: Map<number, UnitConversionEntry>,
): TranslatedRecipeIngredient | null {
if (a.quantity === null || b.quantity === null || a.unitId === null || b.unitId === null) {
return null;
}
if (a.unitId === b.unitId) {
return {
...a,
quantity: a.quantity + b.quantity,
rawText: `${a.rawText} + ${b.rawText}`,
};
}
const unitA = unitById.get(a.unitId);
const unitB = unitById.get(b.unitId);
if (!unitA || !unitB) return null;
if (unitA.type !== unitB.type || unitA.type === "COUNT") return null;
const combinedInBaseUnit = a.quantity * unitA.toBaseFactor + b.quantity * unitB.toBaseFactor;
const quantity = Math.round((combinedInBaseUnit / unitA.toBaseFactor) * 100) / 100;
return { ...a, quantity, rawText: `${a.rawText} + ${b.rawText}` };
}
/**
* Folds `ingredients` down to one line per resolved `ingredientId`,
* concatenating (summing the quantity of) every duplicate into the first
* line it matches see issue #53's follow-up: two raw source lines (e.g.
* TheMealDB's "Egg Yolks"/"Eggs", or "100g Sugar" used in two different
* steps) can independently resolve to the same catalog `Ingredient`, and
* `RecipeIngredient`'s primary key (`recipeId`, `ingredientId`) only
* allows one row per ingredient per recipe the review form used to
* either crash on submit (before `createRecipeSchema` rejected it) or
* require the person to manually delete every extra line by hand.
*
* Unresolved lines (`ingredientId: null`) are never merged with one
* another or with anything else nothing reliable to key them on. Two
* lines that resolve to the same ingredient but can't be combined safely
* (see {@link combineIngredientLines} mismatched quantity/unit, or
* genuinely incompatible units) are left as separate, still-duplicate
* lines: `createRecipeSchema` still rejects the result, and
* `RecipeImportForm` still highlights them, same safety net as before this
* merge step existed merging never *invents* a number it isn't confident
* in.
*
* Pure testable with a hand-built `unitCatalog`, no database involved.
* Order-preserving: a merged line keeps its first occurrence's position.
*/
export function mergeDuplicateIngredients(
ingredients: TranslatedRecipeIngredient[],
unitCatalog: UnitConversionEntry[],
): TranslatedRecipeIngredient[] {
const unitById = new Map(unitCatalog.map((unit) => [unit.id, unit]));
const merged: TranslatedRecipeIngredient[] = [];
const mergedIndexByIngredientId = new Map<number, number>();
for (const line of ingredients) {
const existingIndex =
line.ingredientId !== null ? mergedIndexByIngredientId.get(line.ingredientId) : undefined;
const existingLine = existingIndex !== undefined ? merged[existingIndex] : undefined;
if (existingIndex === undefined || existingLine === undefined) {
if (line.ingredientId !== null) {
mergedIndexByIngredientId.set(line.ingredientId, merged.length);
}
merged.push(line);
continue;
}
const combined = combineIngredientLines(existingLine, line, unitById);
if (combined === null) {
merged.push(line);
} else {
merged[existingIndex] = combined;
}
}
return merged;
}
/**
* Convenience wrapper around {@link translateRecipeSteps}/
* {@link translateRecipeIngredients} that loads every catalog itself
* what a caller reaches for when translating a single recipe on its own
* (e.g. the eventual "import this one recipe" endpoint). A caller
* translating many recipes at once should load the catalogs once and reuse
* them across calls instead, the same "don't requery per item" reasoning
* `recipe.service.ts`'s `createRecipe`/`updateRecipe` already follow for
* manually-authored recipes.
*
* No user- or recipe-level language preference exists anywhere in the app
* yet (see `tech-step-matcher.ts`'s `TechStepClassifierService`) callers
* pass a locale explicitly rather than this module guessing one. Note that
* an English-language source (e.g. TheMealDB) translated against `"fr"`
* will currently get an empty (or nonsensical) `techStepIds` sequence on
* every step the classifier is trained per-locale, so calling it with a
* 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).
*/
export async function translateRecipe(
recipe: ParsedRecipe,
locale: string,
): Promise<TranslatedRecipe> {
try {
const translated = await translateRecipeSteps(recipe, locale);
const [ingredientCatalog, unitCatalog] = await Promise.all([
loadIngredientCatalog(locale),
loadUnitCatalog(locale),
]);
return {
...translated,
ingredients: translateRecipeIngredients(
recipe.ingredients,
ingredientCatalog,
unitCatalog,
locale,
),
};
} catch (err) {
// Rethrown as-is — the caller (`sources.service.ts`) already
// handles/logs failures centrally; this function just isn't allowed a
// bare `await` per the repo's async/try-catch convention.
throw err;
}
}

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,618 +0,0 @@
import { prisma } from "../../db/prisma.js";
import {
findIngredientMentions,
type IngredientMention,
loadIngredientCatalog,
loadUnitCatalog,
} from "./ingredient-matcher.js";
import { intentServiceClient } from "./intent-service-client.js";
/**
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe
* step description corresponds to groundwork for a future batch-cooking
* optimization algorithm, and (via `matchTechStepSpans`) what
* `recipe.service.ts` persists as `StepTechStep.start`/`end` so the recipe
* UI can highlight the exact matched words (see `StepView` in
* `packages/shared`).
*
* Regex-only matching used to live here (matching literal verb-form
* patterns from a DB-backed `TechStepMapping` table) but couldn't
* 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):
*
* 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.
* 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
* 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
* technique is kept only as a fallback for a clause the classifier
* isn't confident about (see `CONFIDENCE_THRESHOLD`) a clearly
* keyword-anchored clause a small model merely isn't sure how to
* classify shouldn't be dropped outright.
*
* A single instruction can genuinely involve more than one technique see
* point 2 above so `matchTechSteps`/`matchTechStepSpans` both return the
* whole *ordered sequence* they find, not a single winner, matching
* `Step.techSteps` (schema.prisma's `StepTechStep`, an ordered join table).
*
* `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).
*/
/**
* Lowercases and strips diacritics (NFD decomposition + removal of
* combining marks, e.g. "Déglacer" -> "deglacer"). Still used by
* `ingredient-matcher.ts` for its own, unrelated free-text matching kept
* here and exported rather than duplicated, this module owned it first.
*/
const COMBINING_DIACRITICS_PATTERN = /\p{Diacritic}/gu;
export function normalizeText(text: string): string {
return text.normalize("NFD").replace(COMBINING_DIACRITICS_PATTERN, "").toLowerCase();
}
/**
* One technique {@link matchTechStepSpans} found, alongside exactly where in
* `description` it matched two nested spans, both `[start, end)` (same
* convention as `String.prototype.slice`):
*
* - `start`/`end` the tight *keyword* span (e.g. "préchauffer") that
* directly triggered the match, or (when no NER anchor exists at all
* see {@link splitIntoClauses}'s zero-candidate case) the whole clause,
* same as `contextStart`/`contextEnd` below.
* - `contextStart`/`contextEnd` the wider *clause* the keyword was found
* in (e.g. "Dans une poêle chaude" for a `preheat` keyword of "poêle
* chaude") what actually got fed to the classifier (see this file's
* doc comment, point 3), kept alongside the tight span so a caller can
* show *both*: the exact trigger word(s), and how much of the sentence
* is understood to be about that technique. Always contains `start`/`end`
* (`contextStart <= start`, `end <= contextEnd`).
*
* 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;
start: number;
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. */
export interface TechniqueCandidate {
/** Training-data `uid` this candidate's synonym belongs to (e.g. `"melt"`) — not yet resolved to a DB id at this stage. */
uid: string;
start: number;
end: number;
}
/** One clause {@link splitIntoClauses} produced — `anchor` is `null` only for the single "whole description, no candidate found at all" fallback clause (see that function's doc comment). */
export interface TechStepClause {
/** `[start, end)` into the original description — the text handed to the classifier for this clause, and (see {@link TechStepMatch}) what ends up as a match's `contextStart`/`contextEnd`. */
start: number;
end: number;
/** The candidate this clause was cut around, if any — its own (tighter) span is what gets persisted as a match's `start`/`end` for the keyword highlight, the wider clause span is always its `contextStart`/`contextEnd`. */
anchor: TechniqueCandidate | null;
}
/** Matches a sentence-ending punctuation mark, for {@link findGapSplitPoint}'s preferred split points. */
const SENTENCE_END_PATTERN = /[.!?]/;
/**
* Picks where to cut the gap `[gapStart, gapEnd)` between two consecutive
* candidates preferring a *sentence* boundary (right after `.`/`!`/`?`)
* nearest the gap's midpoint when one exists in the gap, otherwise any
* whitespace nearest the midpoint, so a clause boundary (surfaced to users
* as `contextStart`/`contextEnd`, unlike a keyword's own `[start, end)`
* which always lands on a real word by construction) never slices through
* the middle of a word found while testing a context span that cut
* "poêle" into "poêl"/"e" across two clauses.
*
* The sentence-boundary preference matters beyond cosmetics: a description
* with two techniques in two different sentences ("Préchauffer le four à
* 180°C. Dans un saladier, mettre le beurre... et mélanger.") used to only
* get a plain nearest-midpoint whitespace split, which for a long first
* sentence lands *inside* the second one handing the classifier a clause
* like "...(thermostat 6). Dans un saladier, mettre" that trails off
* mid-instruction with no object. That garbled, incomplete text is nothing
* like the short, complete training utterances, and was found to
* misclassify real recipe steps with high (>0.65) confidence in both
* halves "Préchauffer..." scored as `mix`, its actual "mélanger" clause
* as `melt`. Splitting at the real sentence boundary instead hands the
* classifier two complete, grammatical clauses, each far closer to what it
* was trained on.
*
* Falls back to the raw midpoint when the gap has no whitespace at all
* (adjacent candidates, or a gap that's pure punctuation with no space)
* same "some split point, however imperfect" fallback a plain midpoint
* always was.
*/
function findGapSplitPoint(description: string, gapStart: number, gapEnd: number): number {
if (gapStart >= gapEnd) return gapStart;
const midpoint = Math.floor((gapStart + gapEnd) / 2);
let bestSentenceEnd: number | null = null;
let bestSentenceEndDistance = Number.POSITIVE_INFINITY;
let bestWhitespace: number | null = null;
let bestWhitespaceDistance = Number.POSITIVE_INFINITY;
for (let i = gapStart; i < gapEnd; i++) {
if (!/\s/.test(description[i] ?? "")) continue;
const distance = Math.abs(i - midpoint);
if (distance < bestWhitespaceDistance) {
bestWhitespace = i;
bestWhitespaceDistance = distance;
}
if (
i > gapStart &&
SENTENCE_END_PATTERN.test(description[i - 1] ?? "") &&
distance < bestSentenceEndDistance
) {
bestSentenceEnd = i;
bestSentenceEndDistance = distance;
}
}
return bestSentenceEnd ?? bestWhitespace ?? midpoint;
}
/**
* Cuts `description` into clauses around `candidates` (NER's found
* technique mentions, already sorted or not sorted internally), one
* clause per candidate, so each can be judged by the classifier on its own
* surrounding context rather than the whole (possibly multi-technique)
* description at once.
*
* - **Zero candidates**: the whole description is one clause with no
* anchor still worth classifying (a description mentioning no literal
* keyword at all can still *mean* a technique, the entire point of the
* classification step), just with no tight span to highlight, so callers
* fall back to highlighting the whole thing.
* - **One candidate**: the whole description is one clause too (nothing to
* cut around a single mention), but *with* that candidate as its anchor
* callers get its tight span for highlighting.
* - **Two or more**: split points fall at the whitespace nearest the
* midpoint of each consecutive pair's `[end, nextStart]` gap (see
* {@link findGapSplitPoint} never mid-word), producing that many
* contiguous, non-overlapping clauses covering the whole description
* clause *i* is anchored on candidate *i*.
*
* Pure and DB/model-free unit-tested directly (see
* `test/tech-step-matcher.test.ts`) without needing a trained classifier.
*/
export function splitIntoClauses(
description: string,
candidates: TechniqueCandidate[],
): TechStepClause[] {
if (candidates.length === 0) {
return [{ start: 0, end: description.length, anchor: null }];
}
const sorted = [...candidates].sort((a, b) => a.start - b.start);
const [first, ...rest] = sorted;
if (first === undefined) {
// Unreachable — `candidates.length === 0` already returned above, so
// `sorted` (same length) always has a first element here. Satisfies
// `noUncheckedIndexedAccess`, which can't see that from the length
// check alone.
return [{ start: 0, end: description.length, anchor: null }];
}
// Single pass, pairing each candidate with the next one as it goes —
// avoids re-indexing a separately-built `splitPoints` array afterward
// (also awkward under `noUncheckedIndexedAccess` for no real benefit,
// since every split point is only ever read once, right after it's
// computed).
const clauses: TechStepClause[] = [];
let clauseStart = 0;
let anchor = first;
for (const next of rest) {
const splitPoint = findGapSplitPoint(description, anchor.end, next.start);
clauses.push({ start: clauseStart, end: splitPoint, anchor });
clauseStart = splitPoint;
anchor = next;
}
clauses.push({ start: clauseStart, end: description.length, anchor });
return clauses;
}
/**
* Below this confidence, a clause's classifier verdict isn't trusted on its
* own falls back to its NER anchor's own technique instead (see this
* file's doc comment, point 3). Tuned empirically against
* `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.
*/
export const CONFIDENCE_THRESHOLD = 0.25;
/**
* 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}
* 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.
*/
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;
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;
/**
* 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.
*/
public async warmUp(): Promise<void> {
try {
await this.matchTechStepSpans("faire cuire à feu doux", "fr");
} catch (err) {
throw err; // see matchTechStepSpans()'s catch comment above
}
}
/**
* Detects every technique `description` means, as an ordered sequence of
* matches (each carrying *where* it matched) empty if none apply. See
* this file's doc comment for the full NER -> split -> classify
* pipeline.
*
* @param locale Which of `TECH_STEP_TRAINING_DATA`'s locales to match
* against same "caller already knows/validated this" contract the
* old `matchTechStepSpans(description, mappings)` had via its
* pre-filtered `mappings` argument, just as an explicit parameter now
* that the training data isn't pre-filtered by the caller anymore.
*/
public async matchTechStepSpans(description: string, locale: string): Promise<TechStepMatch[]> {
try {
await Promise.all([this._ensureTechStepIdsLoaded(), this._ensureUtensilIdsLoaded()]);
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 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");
const clauses = splitIntoClauses(description, candidates);
const matches: TechStepMatch[] = [];
for (const clause of clauses) {
const uid = await this._classifyClause(description, clause, locale);
if (uid === null) continue;
const techStepId = this._techStepIdByUid?.get(uid);
// A `uid` the classifier/NER was trained on but that no longer has
// a matching `TechStep` row (e.g. training data and
// `reference-seed-data.ts` drifted apart) — skip rather than
// 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,
});
}
matches.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId);
return matches;
} 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` without a
// try/catch per the repo's convention.
throw err;
}
}
/**
* 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.
* `recipe-translation.ts`'s `translateRecipeSteps`, which declares a
* step's technique sequence for an imported recipe that isn't saved (and
* so has no `StepTechStep` row to persist a span into) yet.
*/
public async matchTechSteps(description: string, locale: string): Promise<number[]> {
try {
return (await this.matchTechStepSpans(description, locale)).map((match) => match.techStepId);
} catch (err) {
throw err; // see matchTechStepSpans()'s catch comment above
}
}
/**
* Classifies one clause, returning the technique `uid` it means (or
* `null` if none applies) the classifier's own verdict when it's
* confident enough ({@link CONFIDENCE_THRESHOLD}), otherwise the
* clause's NER anchor (if it has one) as a floor: a clearly
* keyword-anchored clause a small model merely isn't sure how to
* classify shouldn't be dropped outright, only a genuinely
* anchor-less/low-confidence one should.
*/
private async _classifyClause(
description: string,
clause: TechStepClause,
locale: string,
): Promise<string | null> {
try {
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) {
return result.intent;
}
return clause.anchor?.uid ?? null;
} catch (err) {
throw err; // see matchTechStepSpans()'s catch comment above
}
}
/**
* 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.
*/
private async _ensureTechStepIdsLoaded(): Promise<void> {
if (this._techStepIdsLoaded === undefined) {
this._techStepIdsLoaded = this._loadTechStepIds();
}
try {
await this._techStepIdsLoaded;
} 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;
throw err;
}
}
private async _loadTechStepIds(): 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;
}
}
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]));
} 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`. */
export const techStepClassifier = new TechStepClassifierService();

View file

@ -16,9 +16,9 @@
* user has already brought in as if they were new. A separate, pure
* step rather than something `list()` itself does: an adapter only
* knows its source, never our database same reasoning as
* `tech-step-matcher.ts`'s split between pure `splitIntoClauses` and
* its DB/model-touching `TechStepClassifierService`. Whichever future
* layer queries "which externalIds from this source do we already have"
* `tech-step-matcher.ts`'s split between pure `matchTechStep` and its
* DB-touching `loadTechStepMappingRules`. Whichever future layer
* queries "which externalIds from this source do we already have"
* (not yet decided it needs a place to persist that link,
* see {@link RecipeSourceListItem.externalId}) calls this to annotate
* the page before returning it.
@ -177,7 +177,7 @@ export interface RecipeSourceAdapter<TRawDetail = unknown> {
* `steps[].description`/`ingredients[].name`) e.g. `"en"` for
* TheMealDB. Not a user preference: the language the source's own
* content is actually written in, regardless of who's browsing it.
* Determines which trained-classifier/ingredient-label locale
* Determines which `TechStepMapping`/ingredient-label locale
* `translateRecipe` (`recipe-translation.ts`) resolves this source's
* recipes against when previewing/importing one.
*/

View file

@ -0,0 +1,163 @@
import {
type IngredientMatchEntry,
type UnitMatchEntry,
extractQuantity,
loadIngredientCatalog,
loadUnitCatalog,
matchIngredientName,
matchUnit,
} from "./ingredient-matcher.js";
import type {
ParsedRecipe,
ParsedRecipeIngredient,
ParsedRecipeStep,
} from "./recipe-source-adapter.js";
import {
type TechStepMappingRule,
loadTechStepMappingRules,
matchTechSteps,
} from "./tech-step-matcher.js";
/**
* The "Traduction en étapes" stage of the import pipeline described in
* specs/batch-cooking-architecture.md (Import depuis source **Traduction
* en étapes** Sauvegarde) takes a source-agnostic {@link ParsedRecipe}
* (recipe-source-adapter.ts's `parse()` output) and declares each step's
* technique sequence, the same `techStepIds: number[]` shape
* `Step.techSteps`/`StepTechStep` (schema.prisma) will eventually persist.
*
* Also resolves ingredients matching each free-text `ParsedRecipeIngredient`
* line against our `Ingredient`/`Unit` catalogs (`ingredient-matcher.ts`),
* the same "free source text -> our catalog id" idea as tech-step
* detection, just for ingredients/units/quantities instead of technique
* verbs. Like tech-step matching, this doesn't turn the result into a
* saveable `Recipe` either (no `dietIds`/`visibility`/author, a source
* can't know those) this is one step of the pipeline, not the whole
* thing.
*
* `translateRecipeSteps`/`translateRecipeIngredients` are pure (take their
* matching data as plain arguments, same convention as `matchTechSteps`/
* `matchIngredientName` themselves) so they're unit-testable without a
* database; `translateRecipe` is the DB-backed convenience wrapper a caller
* reaches for in practice, mirroring `tech-step-matcher.ts`'s own
* pure/DB-touching split.
*/
/** A {@link ParsedRecipeStep}, after tech-step detection — declares its technique sequence alongside the description/picture it already had. */
export interface TranslatedRecipeStep extends ParsedRecipeStep {
/** Ordered sequence of detected `TechStep` ids (see `matchTechSteps`) — empty if this step doesn't mention any known technique. */
techStepIds: number[];
}
/** A {@link ParsedRecipeIngredient}, after ingredient/unit matching — `quantity` is filled in from `rawText` when the source itself left it `null` (see `extractQuantity`); `ingredientId`/`unitId` are `null` when nothing in the catalog matched. */
export interface TranslatedRecipeIngredient extends ParsedRecipeIngredient {
ingredientId: number | null;
unitId: number | null;
}
/** A {@link ParsedRecipe} whose `steps`/`ingredients` have been translated — everything else (name, portions, …) passes through unchanged. */
export interface TranslatedRecipe extends Omit<ParsedRecipe, "steps" | "ingredients"> {
steps: TranslatedRecipeStep[];
ingredients: TranslatedRecipeIngredient[];
}
/**
* Declares each of `recipe`'s steps' technique sequence against
* `techStepMappings`, leaving everything else about the recipe untouched
* including ingredients, which are only stubbed to `TranslatedRecipeIngredient`'s
* shape here (`ingredientId`/`unitId: null`, `quantity`/`unit` untouched);
* actually resolving them is {@link translateRecipeIngredients}'s job, kept
* separate the same way tech-step and ingredient matching are two
* independent concerns everywhere else in this module. Pure testable with
* a hand-built mapping list, no database involved (see `translateRecipe`
* for the DB-backed loader). `techStepMappings` should already be filtered
* to the locale the caller cares about, same requirement `matchTechSteps`
* itself has.
*/
export function translateRecipeSteps(
recipe: ParsedRecipe,
techStepMappings: TechStepMappingRule[],
): TranslatedRecipe {
return {
...recipe,
ingredients: recipe.ingredients.map((ingredient) => ({
...ingredient,
ingredientId: null,
unitId: null,
})),
steps: recipe.steps.map((step) => ({
...step,
techStepIds: matchTechSteps(step.description, techStepMappings),
})),
};
}
/**
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
* against `ingredientCatalog`/`unitCatalog` (see `ingredient-matcher.ts`).
* Pure testable with hand-built catalogs, no database involved (see
* `translateRecipe` for the DB-backed loader). `quantity` falls back to
* `extractQuantity(rawText)` only when the source itself left it `null`;
* same for `unit` falling back to `extractQuantity`'s `remainder` before
* being matched against `unitCatalog` a source that already states a
* clean unit/quantity is trusted over re-deriving it from `rawText`.
*/
export function translateRecipeIngredients(
ingredients: ParsedRecipeIngredient[],
ingredientCatalog: IngredientMatchEntry[],
unitCatalog: UnitMatchEntry[],
): TranslatedRecipeIngredient[] {
return ingredients.map((ingredient) => {
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);
return { ...ingredient, quantity, ingredientId, unitId };
});
}
/**
* Convenience wrapper around {@link translateRecipeSteps}/
* {@link translateRecipeIngredients} that loads every catalog itself
* what a caller reaches for when translating a single recipe on its own
* (e.g. the eventual "import this one recipe" endpoint). A caller
* translating many recipes at once should load the catalogs once and reuse
* them across calls instead, the same "don't requery per item" reasoning
* `recipe.service.ts`'s `createRecipe`/`updateRecipe` already follow for
* manually-authored recipes.
*
* No user- or recipe-level language preference exists anywhere in the app
* yet (see `tech-step-matcher.ts`'s `loadTechStepMappingRules`) callers
* pass a locale explicitly rather than this module guessing one. Note that
* an English-language source (e.g. TheMealDB) translated against `"fr"`
* mappings will currently get an empty `techStepIds` sequence on every
* step matching-language mappings for that source's language don't exist
* yet, this stage doesn't invent them.
*
* 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,
locale: string,
): Promise<TranslatedRecipe> {
const techStepMappings = await loadTechStepMappingRules(locale);
const translated = translateRecipeSteps(recipe, techStepMappings);
if (locale !== "en") return translated;
const [ingredientCatalog, unitCatalog] = await Promise.all([
loadIngredientCatalog(),
loadUnitCatalog(),
]);
return {
...translated,
ingredients: translateRecipeIngredients(recipe.ingredients, ingredientCatalog, unitCatalog),
};
}

View file

@ -0,0 +1,186 @@
import { prisma } from "../db/prisma.js";
/**
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe
* step description corresponds to, using the static `TechStepMapping`
* catalog (see `reference-seed-data.ts`'s `TECH_STEPS`) groundwork for a
* future batch-cooking optimization algorithm, and (via `matchTechStepSpans`)
* what `recipe.service.ts` persists as `StepTechStep.start`/`end` so the
* recipe UI can highlight the exact matched words (see `StepView` in
* `packages/shared`).
*
* A single instruction can genuinely involve more than one technique (e.g.
* "Dans une poêle chaude, faire chauffer une noix de beurre" is both
* `preheat` and `melt`) both `matchTechSteps`/`matchTechStepSpans` return
* the whole *ordered sequence* they find, not a single winner, matching
* `Step.techSteps` (schema.prisma's `StepTechStep`, an ordered join table).
*
* `normalizeText`/`matchTechStepSpans`/`matchTechSteps` are pure (no DB
* access) so they can be unit-tested in isolation (see
* `test/tech-step-matcher.test.ts`). `loadTechStepMappingRules` is the only
* DB-touching piece, kept separate so callers (`recipe.service.ts`) fetch
* the whole mapping list once per request and pass it to
* `matchTechStepSpans` per step, rather than querying once per step.
*/
/** One `TechStepMapping` row, trimmed to what {@link matchTechSteps} needs. */
export interface TechStepMappingRule {
techStepId: number;
/**
* Regex source, matched against the normalized description (see
* {@link normalizeText}) may itself contain accented characters,
* normalized the same way before compiling.
*/
expression: string;
weight: number;
}
/**
* Lowercases and strips diacritics (NFD decomposition + removal of
* combining marks, e.g. "Déglacer" -> "deglacer") recipe step text and
* mapping expressions are both run through this before matching, so
* expressions can be authored with natural French accents in
* `reference-seed-data.ts` while matching stays accent/case-insensitive.
*/
const COMBINING_DIACRITICS_PATTERN = /\p{Diacritic}/gu;
export function normalizeText(text: string): string {
return text.normalize("NFD").replace(COMBINING_DIACRITICS_PATTERN, "").toLowerCase();
}
/** Where in the (normalized) description one mapping matched, alongside the rule that matched — the raw material {@link matchTechStepSpans} resolves into a final sequence. */
interface MatchCandidate extends TechStepMappingRule {
start: number;
end: number;
}
/** Whether two candidates' matched spans share any character position — the case where two *different* techniques' expressions matched the same words (e.g. generic `cook`'s "cuire" inside specific `bake`'s "cuire au four"), meaning only one of them should survive. */
function overlaps(a: MatchCandidate, b: MatchCandidate): boolean {
return a.start < b.end && b.start < a.end;
}
/**
* One technique {@link matchTechStepSpans} found, alongside exactly where in
* `description` it matched `[start, end)`, same convention as
* `String.prototype.slice`. Persisted as `StepTechStep.start`/`end`
* (`recipe.service.ts`) so the recipe detail view can highlight the exact
* matched words, not just know a technique was mentioned somewhere.
*/
export interface TechStepMatch {
techStepId: number;
start: number;
end: number;
}
/**
* Detects every technique `description` mentions among `mappings`, as an
* ordered sequence of matches (each carrying *where* it matched) empty if
* none match. The algorithm:
*
* 1. Test every mapping against the normalized description; each one that
* matches becomes a candidate carrying *where* it matched (so
* overlapping matches can be compared).
* 2. Within a single technique, several of its own mappings might all
* match (different phrasings for the same `techStepId`) keep only
* that technique's best candidate (highest weight, ties broken by
* earliest match), the same tie-break this function always used for a
* single winner.
* 3. Across *different* techniques, two candidates can still overlap (a
* generic pattern matching inside a more specific one's span, e.g.
* `cook` vs `bake` both matching "cuire au four") resolve greedily by
* weight: take candidates highest-weight first, accept a candidate only
* if it doesn't overlap one already accepted. This is what keeps
* `bake` and drops the redundant `cook` for that phrase, while letting
* two genuinely distinct, non-overlapping techniques (e.g. `preheat`
* and `melt` in "Dans une poêle chaude, faire chauffer une noix de
* beurre") both survive.
* 4. Sort what's left by where it appears in the text the sequence
* reads in the same order as the instruction itself.
*
* The returned `start`/`end` are offsets into `normalizeText(description)`,
* used as-is against the *original* `description` by callers that slice it
* for display (`highlight-tech-steps.ts`, apps/web) `normalizeText` only
* strips diacritics/lowercases, which preserves character count for
* realistic French text (canonical NFD decomposition never turns one
* character into more than one base character), so this holds in practice.
* A pathological input where it doesn't (e.g. a bare standalone `^`, which
* `normalizeText` would strip as a diacritic) just produces a slightly
* misplaced highlight degrades silently, doesn't crash.
*
* Pure takes `mappings` as a plain argument rather than querying Prisma
* itself, so it's testable without a database (see
* `loadTechStepMappingRules` for the DB-backed loader). `mappings` should
* already be filtered to the locale the caller cares about this function
* has no notion of locale, it just tests the rules it's given.
*/
export function matchTechStepSpans(
description: string,
mappings: TechStepMappingRule[],
): TechStepMatch[] {
const normalizedDescription = normalizeText(description);
const candidates: MatchCandidate[] = [];
for (const mapping of mappings) {
const pattern = new RegExp(normalizeText(mapping.expression), "i");
const match = pattern.exec(normalizedDescription);
if (match === null) continue;
candidates.push({ ...mapping, start: match.index, end: match.index + match[0].length });
}
// Step 2: one best candidate per techStepId.
const bestByTechStep = new Map<number, MatchCandidate>();
for (const candidate of candidates) {
const current = bestByTechStep.get(candidate.techStepId);
if (
current === undefined ||
candidate.weight > current.weight ||
(candidate.weight === current.weight && candidate.start < current.start)
) {
bestByTechStep.set(candidate.techStepId, candidate);
}
}
// Step 3: resolve cross-technique overlaps, highest weight first.
const byWeightDesc = [...bestByTechStep.values()].sort(
(a, b) => b.weight - a.weight || a.techStepId - b.techStepId,
);
const accepted: MatchCandidate[] = [];
for (const candidate of byWeightDesc) {
if (accepted.some((other) => overlaps(candidate, other))) continue;
accepted.push(candidate);
}
// Step 4: reading order.
accepted.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId);
return accepted.map(({ techStepId, start, end }) => ({ techStepId, start, end }));
}
/**
* Convenience wrapper around {@link matchTechStepSpans} for callers that
* only care about *which* techniques matched, not where e.g.
* `recipe-translation.ts`'s `translateRecipeSteps`, which declares a step's
* technique sequence for an imported recipe that isn't saved (and so has no
* `StepTechStep` row to persist a span into) yet.
*/
export function matchTechSteps(description: string, mappings: TechStepMappingRule[]): number[] {
return matchTechStepSpans(description, mappings).map((match) => match.techStepId);
}
/**
* Loads every `TechStepMapping` row for `locale` as
* {@link TechStepMappingRule}s meant to be fetched once per request by
* `recipe.service.ts`'s `createRecipe`/`updateRecipe` and reused across
* every step of the recipe being saved, not re-queried per step.
*
* No user-language preference exists anywhere in the app yet (a single
* `"fr"` translation file, no locale field on `User`/`UserProfile`)
* callers pass a hardcoded locale for now; this parameter exists so that
* plugging in a real user preference later doesn't require touching this
* module.
*/
export async function loadTechStepMappingRules(locale: string): Promise<TechStepMappingRule[]> {
return prisma.techStepMapping.findMany({
where: { locale },
select: { techStepId: true, expression: true, weight: true },
});
}

View file

@ -1,40 +0,0 @@
import { errorHandlerService } from "@batch-cooking/error-tools";
import type { NextFunction, Request, Response } from "express";
import { logger } from "../lib/logger.service.js";
/**
* Logs every error that reaches Express's error-handling chain, then
* passes it straight on (`next(err)`) to the real error-to-response
* middleware (`createErrorMiddleware`, `@batch-cooking/express-tools`)
* mounted immediately after this one in `app.ts`. Reuses
* `errorHandlerService.handle()` (`@batch-cooking/error-tools`) just to
* classify the error for logging purposes (its `status`/`body.code`)
* `.handle()` is pure/stateless, so calling it here and then again in
* `createErrorMiddleware` right after is harmless, and it's the one place
* that already knows a bare `ZodError` maps to `400 VALIDATION_ERROR`, an
* `HttpError` maps to its own `status`/`code`, and anything else is a
* `500`. Doesn't build the actual response itself — that's still
* `createErrorMiddleware`'s job.
*
* A resulting `4xx` is routine, expected operation (a validation failure,
* a 404, an unauthenticated request) logged at `warn`, not `error`, so a
* genuine `5xx` (an unhandled exception, a bug) stands out instead of
* being buried under normal client mistakes.
*/
export function errorLogger(err: unknown, req: Request, _res: Response, next: NextFunction): void {
const meta = { method: req.method, path: req.originalUrl };
const { status, body } = errorHandlerService.handle(err);
if (status >= 500) {
logger.error(err instanceof Error ? err.message : body.message, {
...meta,
status,
code: body.code,
stack: err instanceof Error ? err.stack : undefined,
});
} else {
logger.warn(body.message, { ...meta, status, code: body.code });
}
next(err);
}

View file

@ -1,45 +0,0 @@
import type { NextFunction, Request, Response } from "express";
import { logger } from "../lib/logger.service.js";
/**
* Logs one line per request once it finishes method, path, status code,
* and duration. Mounted first in `app.ts` (before every route, and before
* the error handler) so it wraps the whole request/response cycle,
* including requests that end in a 404 or an error response.
*
* Listens on `res`'s `"finish"` event rather than wrapping `next()` in a
* `try`/`finally`: this middleware calls `next()` immediately and returns,
* so it never itself sits on the stack waiting for the rest of the
* pipeline to resolve `"finish"` fires once Express has actually flushed
* the response, whichever handler (or the error middleware) produced it.
*
* `4xx`/`5xx` responses log at `warn`/`error` respectively (status alone
* decides the level this middleware has no idea *why* a request failed,
* just that it did); everything else logs at `info`. A route's own
* handler/the error middleware may log more detail about *why* separately
* (see `error-middleware` wiring in `app.ts`) this line is just the
* "a request happened, here's the outcome" operational trace.
*/
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
const startedAt = process.hrtime.bigint();
res.on("finish", () => {
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
const meta = {
method: req.method,
path: req.originalUrl,
status: res.statusCode,
durationMs: Math.round(durationMs * 100) / 100,
};
if (res.statusCode >= 500) {
logger.error("Request completed", meta);
} else if (res.statusCode >= 400) {
logger.warn("Request completed", meta);
} else {
logger.info("Request completed", meta);
}
});
next();
}

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,7 +1,7 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared";
import type { CookieOptions, Response } from "express";
import { Router } from "express";
import type { CookieOptions, Response } from "express";
import { env } from "../../config/env.js";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { deleteAccount, login, signup } from "./auth.service.js";

View file

@ -35,10 +35,7 @@ const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
*/
export async function signup(input: SignupInput): Promise<AuthResult> {
try {
const existing = await prisma.userProfile.findUnique({
where: { email: input.email },
});
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
if (existing) {
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
}
@ -58,18 +55,8 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
},
});
const token = signAuthToken({
userProfileId: profile.id,
tokenVersion: profile.tokenVersion,
});
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
return { profile: toSafeProfile(profile), token };
} 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;
}
}
/**
@ -87,10 +74,7 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
* @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong.
*/
export async function deleteAccount(profileId: number, password: string): Promise<void> {
try {
const profile = await prisma.userProfile.findUnique({
where: { id: profileId },
});
const profile = await prisma.userProfile.findUnique({ where: { id: profileId } });
if (!profile || !(await argon2.verify(profile.passwordHash, password))) {
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password");
}
@ -99,9 +83,6 @@ export async function deleteAccount(profileId: number, password: string): Promis
await leaveCurrentHouse(profile.id, profile.houseId);
}
await prisma.userProfile.delete({ where: { id: profile.id } });
} catch (err) {
throw err; // see signup()'s catch comment above
}
}
/**
@ -112,21 +93,12 @@ export async function deleteAccount(profileId: number, password: string): Promis
* caller can never learn whether a given email has an account.
*/
export async function login(input: LoginInput): Promise<AuthResult> {
try {
const profile = await prisma.userProfile.findUnique({
where: { email: input.email },
});
const profile = await prisma.userProfile.findUnique({ where: { email: input.email } });
if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) {
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
}
const token = signAuthToken({
userProfileId: profile.id,
tokenVersion: profile.tokenVersion,
});
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
return { profile: toSafeProfile(profile), token };
} catch (err) {
throw err; // see signup()'s catch comment above
}
}

View file

@ -1,8 +1,8 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
createHouseSchema,
ErrorCode,
createHouseSchema,
joinHouseSchema,
renameHouseSchema,
updateHouseSourcesSchema,

View file

@ -44,18 +44,10 @@ const houseWithMembers = {
/** Returns the profile's household (with its member list), or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */
export async function getCurrentHouse(houseId: number | null): Promise<HouseView | null> {
try {
if (houseId === null) {
return null;
}
return toHouseView(await findHouseOrThrow(houseId));
} 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;
}
}
/**
@ -66,7 +58,6 @@ export async function getCurrentHouse(houseId: number | null): Promise<HouseView
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
*/
export async function renameHouse(houseId: number | null, name: string): Promise<HouseView> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
@ -77,9 +68,6 @@ export async function renameHouse(houseId: number | null, name: string): Promise
include: houseWithMembers,
});
return toHouseView(house);
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}
/**
@ -93,13 +81,8 @@ export async function createHouse(
houseId: number | null,
name: string,
): Promise<HouseView> {
try {
if (houseId !== null) {
throw new HttpError(
409,
ErrorCode.ALREADY_HAS_HOUSE,
"Profile already belongs to a household",
);
throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
}
// Astronomically unlikely to collide (33^8 possibilities), but retried
@ -110,28 +93,18 @@ export async function createHouse(
try {
const house = await prisma.$transaction(async (tx) => {
const created = await tx.house.create({
data: {
name,
adminId: profileId,
inviteCode: generateInviteCode(),
},
});
await tx.userProfile.update({
where: { id: profileId },
data: { houseId: created.id },
data: { name, adminId: profileId, inviteCode: generateInviteCode() },
});
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: created.id } });
return created;
});
return await getCurrentHouseOrThrow(house.id);
return getCurrentHouseOrThrow(house.id);
} catch (err) {
if (isUniqueInviteCodeViolation(err) && attempt < maxAttempts) continue;
throw err;
}
}
throw new Error("Failed to generate a unique invite code after several attempts");
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}
/**
@ -145,13 +118,8 @@ export async function joinHouse(
houseId: number | null,
inviteCode: string,
): Promise<HouseView> {
try {
if (houseId !== null) {
throw new HttpError(
409,
ErrorCode.ALREADY_HAS_HOUSE,
"Profile already belongs to a household",
);
throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
}
const house = await prisma.house.findUnique({ where: { inviteCode } });
@ -163,14 +131,8 @@ export async function joinHouse(
);
}
await prisma.userProfile.update({
where: { id: profileId },
data: { houseId: house.id },
});
return await getCurrentHouseOrThrow(house.id);
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
await prisma.userProfile.update({ where: { id: profileId }, data: { houseId: house.id } });
return getCurrentHouseOrThrow(house.id);
}
/**
@ -187,7 +149,6 @@ export async function joinHouse(
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household.
*/
export async function leaveCurrentHouse(profileId: number, houseId: number | null): Promise<void> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
@ -196,10 +157,7 @@ export async function leaveCurrentHouse(profileId: number, houseId: number | nul
const remainingMembers = house.members.filter((member) => member.id !== profileId);
await prisma.$transaction(async (tx) => {
await tx.userProfile.update({
where: { id: profileId },
data: { houseId: null },
});
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: null } });
if (house.adminId !== profileId) {
return;
@ -211,14 +169,8 @@ export async function leaveCurrentHouse(profileId: number, houseId: number | nul
const nextAdmin = remainingMembers.reduce((oldest, member) =>
member.id < oldest.id ? member : oldest,
);
await tx.house.update({
where: { id: house.id },
data: { adminId: nextAdmin.id },
await tx.house.update({ where: { id: house.id }, data: { adminId: nextAdmin.id } });
});
});
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}
/**
@ -231,32 +183,21 @@ export async function leaveCurrentHouse(profileId: number, houseId: number | nul
* @throws {HttpError} `403 NOT_HOUSE_ADMIN` if the profile isn't this household's admin.
*/
export async function deleteHouse(profileId: number, houseId: number | null): Promise<void> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
const house = await findHouseOrThrow(houseId);
if (house.adminId !== profileId) {
throw new HttpError(
403,
ErrorCode.NOT_HOUSE_ADMIN,
"Only the household's admin can delete it",
);
throw new HttpError(403, ErrorCode.NOT_HOUSE_ADMIN, "Only the household's admin can delete it");
}
// Members' houseId also cascades to null via the FK's onDelete: SetNull,
// but clearing it explicitly first keeps the outcome obvious without
// relying on that FK behavior being read alongside this function.
await prisma.$transaction([
prisma.userProfile.updateMany({
where: { houseId: house.id },
data: { houseId: null },
}),
prisma.userProfile.updateMany({ where: { houseId: house.id }, data: { houseId: null } }),
prisma.house.delete({ where: { id: house.id } }),
]);
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}
/**
@ -274,7 +215,6 @@ export async function removeMember(
houseId: number | null,
targetMemberId: number,
): Promise<HouseView> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
@ -297,14 +237,8 @@ export async function removeMember(
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Not a member of this household");
}
await prisma.userProfile.update({
where: { id: targetMemberId },
data: { houseId: null },
});
return await getCurrentHouseOrThrow(house.id);
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
await prisma.userProfile.update({ where: { id: targetMemberId }, data: { houseId: null } });
return getCurrentHouseOrThrow(house.id);
}
/**
@ -315,7 +249,6 @@ export async function removeMember(
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
*/
export async function getHouseSourceIds(houseId: number | null): Promise<number[]> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
@ -324,9 +257,6 @@ export async function getHouseSourceIds(houseId: number | null): Promise<number[
select: { sourceId: true },
});
return rows.map((row) => row.sourceId);
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}
/**
@ -343,7 +273,6 @@ export async function updateHouseSources(
houseId: number | null,
sourceIds: number[],
): Promise<number[]> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
@ -365,24 +294,15 @@ export async function updateHouseSources(
await prisma.$transaction([
prisma.houseSource.deleteMany({ where: { houseId } }),
prisma.houseSource.createMany({
data: sourceIds.map((sourceId) => ({ houseId, sourceId })),
}),
prisma.houseSource.createMany({ data: sourceIds.map((sourceId) => ({ houseId, sourceId })) }),
]);
return sourceIds;
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}
/** Re-fetches a household by id (as a {@link HouseView}) once its id is already known to be valid — the common "reload after a mutation" step shared by several functions above. */
async function getCurrentHouseOrThrow(houseId: number): Promise<HouseView> {
try {
return toHouseView(await findHouseOrThrow(houseId));
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}
/** True if `err` is Prisma's unique-constraint violation (`P2002`) on `invite_code` — the only expected cause of a collision retry in {@link createHouse}. */
@ -404,7 +324,6 @@ function isUniqueInviteCodeViolation(err: unknown): boolean {
* `HOUSE_NOT_FOUND` HttpError.
*/
async function findHouseOrThrow(houseId: number) {
try {
const house = await prisma.house.findUnique({
where: { id: houseId },
include: houseWithMembers,
@ -413,7 +332,4 @@ async function findHouseOrThrow(houseId: number) {
throw new Error(`House ${houseId} referenced by a profile but not found`);
}
return house;
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}

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,7 +1,7 @@
import { parseDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { addPlanningItemSchema, ErrorCode, getPlanningByDateSchema } from "@batch-cooking/shared";
import { ErrorCode, addPlanningItemSchema, getPlanningByDateSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { addPlanningItem, getPlanningForDate, removePlanningItem } from "./planning.service.js";

View file

@ -27,7 +27,6 @@ export async function getPlanningForDate(
houseId: number | null,
date: DateTime,
): Promise<PlanningView | null> {
try {
if (houseId === null) {
return null;
}
@ -71,13 +70,6 @@ export async function getPlanningForDate(
recipe: item.recipe,
})),
};
} 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;
}
}
/**
@ -96,24 +88,14 @@ export async function getPlanningForDate(
* scale rather than adding a migration + retry-on-conflict loop for it.
*/
async function findOrCreatePlanningForWeek(houseId: number, weekStart: DateTime) {
try {
const startDate = weekStart.toJSDate();
const existing = await prisma.planning.findFirst({
where: { houseId, startDate },
});
const existing = await prisma.planning.findFirst({ where: { houseId, startDate } });
if (existing) {
return existing;
}
return await prisma.planning.create({
data: {
houseId,
startDate,
finishDate: weekStart.plus({ days: 6 }).toJSDate(),
},
return prisma.planning.create({
data: { houseId, startDate, finishDate: weekStart.plus({ days: 6 }).toJSDate() },
});
} catch (err) {
throw err; // see getPlanningForDate()'s catch comment above
}
}
/**
@ -137,7 +119,6 @@ export async function addPlanningItem(
date: DateTime,
input: AddPlanningItemInput,
): Promise<PlanningItemView> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
@ -164,9 +145,6 @@ export async function addPlanningItem(
portions: item.portions,
recipe: item.recipe,
};
} catch (err) {
throw err; // see getPlanningForDate()'s catch comment above
}
}
/**
@ -178,7 +156,6 @@ export async function addPlanningItem(
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `id` doesn't match any planning item, or does but belongs to a planning outside `houseId` — never `403`, same "don't confirm what exists" reasoning as `RECIPE_NOT_FOUND` elsewhere.
*/
export async function removePlanningItem(id: number, houseId: number | null): Promise<void> {
try {
const item = await prisma.planningItem.findUnique({
where: { id },
include: { planning: true },
@ -187,7 +164,4 @@ export async function removePlanningItem(id: number, houseId: number | null): Pr
throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
}
await prisma.planningItem.delete({ where: { id } });
} catch (err) {
throw err; // see getPlanningForDate()'s catch comment above
}
}

View file

@ -9,18 +9,8 @@ import { prisma } from "../../db/prisma.js";
* just to read it.
*/
export async function getPreferences(userProfileId: number): Promise<PreferencesView> {
try {
const preferences = await prisma.userPreference.findUnique({
where: { userProfileId },
});
const preferences = await prisma.userPreference.findUnique({ where: { userProfileId } });
return { theme: preferences?.theme ?? "SYSTEM" };
} 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;
}
}
/**
@ -32,14 +22,10 @@ export async function updatePreferences(
userProfileId: number,
theme: ThemePreference,
): Promise<PreferencesView> {
try {
const preferences = await prisma.userPreference.upsert({
where: { userProfileId },
create: { userProfileId, theme },
update: { theme },
});
return { theme: preferences.theme };
} catch (err) {
throw err; // see getPreferences()'s catch comment above
}
}

View file

@ -14,7 +14,6 @@ export async function updateDiet(
userProfileId: number,
dietId: number | null,
): Promise<SafeUserProfile> {
try {
if (dietId !== null) {
const diet = await prisma.diet.findUnique({ where: { id: dietId } });
if (!diet) {
@ -27,26 +26,15 @@ export async function updateDiet(
data: { dietId },
});
return toSafeProfile(profile);
} 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;
}
}
/** Current allergen ids for a profile — an empty array is normal (no allergies declared, or the step was skipped). */
export async function getAllergyIds(userProfileId: number): Promise<number[]> {
try {
const rows = await prisma.userProfileAllergy.findMany({
where: { userProfileId },
select: { allergyId: true },
});
return rows.map((row) => row.allergyId);
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
}
/**
@ -61,7 +49,6 @@ export async function updateAllergies(
userProfileId: number,
allergyIds: number[],
): Promise<number[]> {
try {
if (allergyIds.length > 0) {
const found = await prisma.allergy.findMany({
where: { id: { in: allergyIds } },
@ -86,22 +73,15 @@ export async function updateAllergies(
]);
return allergyIds;
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
}
/** Current disliked-ingredient ids for a profile — an empty array is normal (no dislikes declared). A taste preference, not a medical restriction — see {@link getAllergyIds} for that distinct list. */
export async function getDislikedIngredientIds(userProfileId: number): Promise<number[]> {
try {
const rows = await prisma.userProfileDislikedIngredient.findMany({
where: { userProfileId },
select: { ingredientId: true },
});
return rows.map((row) => row.ingredientId);
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
}
/**
@ -115,7 +95,6 @@ export async function updateDislikedIngredients(
userProfileId: number,
dislikedIngredientIds: number[],
): Promise<number[]> {
try {
if (dislikedIngredientIds.length > 0) {
const found = await prisma.ingredient.findMany({
where: { id: { in: dislikedIngredientIds } },
@ -133,19 +112,11 @@ export async function updateDislikedIngredients(
}
await prisma.$transaction([
prisma.userProfileDislikedIngredient.deleteMany({
where: { userProfileId },
}),
prisma.userProfileDislikedIngredient.deleteMany({ where: { userProfileId } }),
prisma.userProfileDislikedIngredient.createMany({
data: dislikedIngredientIds.map((ingredientId) => ({
userProfileId,
ingredientId,
})),
data: dislikedIngredientIds.map((ingredientId) => ({ userProfileId, ingredientId })),
}),
]);
return dislikedIngredientIds;
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
}

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

@ -1,10 +1,9 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
createRecipeSchema,
ErrorCode,
createRecipeSchema,
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

@ -14,46 +14,18 @@ import {
} from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import {
type TechStepMatch,
techStepClassifier,
} from "../../lib/recipe-matching/tech-step-matcher.js";
import { loadTechStepMappingRules, matchTechStepSpans } from "../../lib/tech-step-matcher.js";
// No user-language preference exists anywhere in the app yet (a single
// "fr" translation file, no locale field on User/UserProfile) — steps are
// matched against this hardcoded locale for now. See
// `tech-step-matcher.ts`'s `TechStepClassifierService` for why the locale
// is a parameter rather than baked into that module.
// `tech-step-matcher.ts`'s `loadTechStepMappingRules` for why the locale is
// a parameter rather than baked into that module.
const DEFAULT_TECH_STEP_LOCALE = "fr";
/** Prisma `include` for every query that needs a full {@link RecipeView} — ingredients resolved to their reference data + allergens, steps in order, diet tags, and whether `viewerId` has favorited it. Parameterized by viewer since `favoritedBy` is per-viewer, not a static shape. */
function recipeInclude(viewerId: number) {
return {
ingredients: {
include: {
ingredient: {
include: {
allergies: {
include: { allergy: { include: { category: true } } },
},
diets: { include: { diet: true } },
},
},
unit: true,
},
},
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: {
@ -65,36 +37,26 @@ function recipeInclude(viewerId: number) {
unit: true,
},
},
utensils: { include: { utensil: true } },
},
},
},
steps: {
orderBy: { order: "asc" },
include: { techSteps: { orderBy: { order: "asc" }, include: { techStep: true } } },
},
diets: { include: { diet: true } },
favoritedBy: { where: { userProfileId: viewerId } },
} satisfies Prisma.RecipeInclude;
}
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 RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType<typeof recipeInclude> }>;
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 {
return {
id: unit.id,
key: unit.key,
type: unit.type,
toBaseFactor: Number(unit.toBaseFactor),
};
/** 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, type: unit.type, toBaseFactor: Number(unit.toBaseFactor) };
}
/** 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,
@ -147,57 +109,21 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
/**
* Shapes a step's `StepTechStep` rows into {@link StepTechStepView}s a row
* whose `start`/`end` is still `null` (a pre-existing row saved before that
* column pair existed, not yet recomputed by a resave see the schema doc
* whose `start`/`end` is still `null` (a pre-existing row saved before this
* column existed, not yet recomputed by a resave see the schema doc
* comment on `StepTechStep`) is dropped rather than surfaced with a null
* span, so the frontend only ever deals with real, highlightable matches.
* `contextStart`/`contextEnd` are treated more leniently a row with a
* real keyword span but no context (saved before *that* column pair
* 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;
if (start === null || end === null) continue;
if (stepTechStep.start === null || stepTechStep.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",
})),
techStep: { id: stepTechStep.techStep.id, key: stepTechStep.techStep.key },
start: stepTechStep.start,
end: stepTechStep.end,
});
}
return views;
@ -231,11 +157,7 @@ function toRecipeView(recipe: RecipeWithDetails): RecipeView {
* `RecipeVisibility` in schema.prisma.
*/
function canView(
recipe: {
authorId: number;
authorHouseId: number | null;
visibility: string;
},
recipe: { authorId: number; authorHouseId: number | null; visibility: string },
viewerId: number,
viewerHouseId: number | null,
): boolean {
@ -279,7 +201,6 @@ function visibleToViewerWhere(
* belong in a filter framed around what's safe/appropriate to serve.
*/
async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.RecipeWhereInput> {
try {
const members = await prisma.userProfile.findMany({
where: { houseId },
select: { dietId: true, allergies: { select: { allergyId: true } } },
@ -296,29 +217,16 @@ async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.Recipe
// Every diet declared by a member must be among this recipe's tags —
// not "at least one", since a recipe suiting a vegetarian member
// doesn't automatically suit a gluten-free one too.
conditions.push({
AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })),
});
conditions.push({ AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })) });
}
if (excludedAllergyIds.length > 0) {
conditions.push({
ingredients: {
none: {
ingredient: {
allergies: { some: { allergyId: { in: excludedAllergyIds } } },
},
},
none: { ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } } },
},
});
}
return { AND: conditions };
} 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`/`async` body
// without a try/catch per the repo's convention.
throw err;
}
}
/**
@ -333,20 +241,13 @@ async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.Recipe
* is hidden for them until they join or create one and configure it.
*/
async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.RecipeWhereInput> {
try {
const enabledSourceIds =
houseId === null
? []
: (
await prisma.houseSource.findMany({
where: { houseId },
select: { sourceId: true },
})
).map((row) => row.sourceId);
: (await prisma.houseSource.findMany({ where: { houseId }, select: { sourceId: true } })).map(
(row) => row.sourceId,
);
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -382,7 +283,6 @@ export async function listRecipes(
tab: RecipeTab,
filters: ListRecipesFilters = {},
): Promise<RecipeSummaryView[]> {
try {
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
if (search) {
@ -398,15 +298,11 @@ export async function listRecipes(
// them, not just one, same "every one, not any one" posture as
// suitableForHouseholdWhere's requiredDietIds.
conditions.push({
AND: ingredientIds.map((ingredientId) => ({
ingredients: { some: { ingredientId } },
})),
AND: ingredientIds.map((ingredientId) => ({ ingredients: { some: { ingredientId } } })),
});
}
if (dietIds && dietIds.length > 0) {
conditions.push({
AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })),
});
conditions.push({ AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })) });
}
switch (tab) {
@ -433,9 +329,6 @@ export async function listRecipes(
orderBy: { name: "asc" },
});
return recipes.map(toRecipeSummaryView);
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -448,15 +341,11 @@ export async function getRecipe(
viewerId: number,
viewerHouseId: number | null,
): Promise<RecipeView> {
try {
const recipe = await findRecipeOrThrow(id, viewerId);
if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
return toRecipeView(recipe);
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -476,11 +365,7 @@ export async function createRecipe(
authorId: number,
authorHouseId: number | null,
): Promise<RecipeView> {
try {
return await createRecipeInternal(input, authorId, authorHouseId, null);
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
return createRecipeInternal(input, authorId, authorHouseId, null);
}
/**
@ -504,41 +389,7 @@ export async function createImportedRecipe(
authorHouseId: number | null,
source: { sourceId: number; externalId: string; locale: string },
): Promise<RecipeView> {
try {
return await createRecipeInternal(input, authorId, authorHouseId, source);
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/** One input step, bundled with its own technique matches — see {@link matchStepsTechSteps}. */
interface StepWithTechSteps<T> {
step: T;
matches: TechStepMatch[];
}
/**
* Matches every one of `steps`' technique sequence against `locale`, in
* parallel, each bundled back with its own originating step (rather than
* returned as a same-length array callers would have to re-zip with
* `steps` by index `noUncheckedIndexedAccess` makes that genuinely
* awkward for no benefit, since every match list is only ever read back
* once) the shared prep step {@link createRecipeInternal}/
* {@link updateRecipe} both need before building their (synchronous)
* Prisma `create` payload, now that matching itself is async
* (`techStepClassifier`, a trained model rather than a pure regex test
* see `tech-step-matcher.ts`).
*/
async function matchStepsTechSteps<T extends { description: string }>(
steps: T[],
locale: string,
): Promise<StepWithTechSteps<T>[]> {
return Promise.all(
steps.map(async (step) => ({
step,
matches: await techStepClassifier.matchTechStepSpans(step.description, locale),
})),
);
return createRecipeInternal(input, authorId, authorHouseId, source);
}
async function createRecipeInternal(
@ -547,17 +398,10 @@ async function createRecipeInternal(
authorHouseId: number | null,
source: { sourceId: number; externalId: string; locale: string } | null,
): Promise<RecipeView> {
try {
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
await assertDietsExist(input.dietIds);
// Matched up front (one call per step, in parallel) rather than inline
// inside the `steps.create` map below — `techStepClassifier` is async
// (a trained model, not a pure regex test), so its result has to
// already be in hand by the time this synchronous Prisma payload is
// built.
const stepsWithTechSteps = await matchStepsTechSteps(
input.steps,
const techStepMappings = await loadTechStepMappingRules(
source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
);
@ -580,34 +424,16 @@ async function createRecipeInternal(
})),
},
steps: {
create: stepsWithTechSteps.map(({ step, matches }, index) => ({
create: input.steps.map((step, index) => ({
description: step.description,
picture: step.picture ?? null,
order: index,
techSteps: {
create: matches.map((match, order) => ({
create: matchTechStepSpans(step.description, techStepMappings).map((match, order) => ({
techStepId: match.techStepId,
start: match.start,
end: match.end,
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,
})),
},
})),
},
})),
@ -617,9 +443,6 @@ async function createRecipeInternal(
include: recipeInclude(authorId),
});
return toRecipeView(created);
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -641,12 +464,11 @@ export async function updateRecipe(
viewerId: number,
viewerHouseId: number | null,
): Promise<RecipeView> {
try {
await assertIsAuthor(id, viewerId, viewerHouseId);
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
await assertDietsExist(input.dietIds);
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
const techStepMappings = await loadTechStepMappingRules(DEFAULT_TECH_STEP_LOCALE);
await prisma.$transaction([
prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }),
@ -668,19 +490,19 @@ export async function updateRecipe(
})),
},
steps: {
create: stepsWithTechSteps.map(({ step, matches }, index) => ({
create: input.steps.map((step, index) => ({
description: step.description,
picture: step.picture ?? null,
order: index,
techSteps: {
create: matches.map((match, order) => ({
create: matchTechStepSpans(step.description, techStepMappings).map(
(match, order) => ({
techStepId: match.techStepId,
start: match.start,
end: match.end,
contextStart: match.contextStart,
contextEnd: match.contextEnd,
order,
})),
}),
),
},
})),
},
@ -690,9 +512,6 @@ export async function updateRecipe(
]);
return toRecipeView(await findRecipeOrThrow(id, viewerId));
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -708,12 +527,9 @@ export async function deleteRecipe(
viewerId: number,
viewerHouseId: number | null,
): Promise<void> {
try {
await assertIsAuthor(id, viewerId, viewerHouseId);
const usedInPlanning = await prisma.planningItem.findFirst({
where: { recipeId: id },
});
const usedInPlanning = await prisma.planningItem.findFirst({ where: { recipeId: id } });
if (usedInPlanning) {
throw new HttpError(
409,
@ -723,9 +539,6 @@ export async function deleteRecipe(
}
await prisma.recipe.delete({ where: { id } });
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -739,32 +552,20 @@ export async function addFavorite(
viewerId: number,
viewerHouseId: number | null,
): Promise<void> {
try {
const recipe = await findRecipeOrThrow(id, viewerId);
if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
await prisma.recipeFavorite.upsert({
where: {
userProfileId_recipeId: { userProfileId: viewerId, recipeId: id },
},
where: { userProfileId_recipeId: { userProfileId: viewerId, recipeId: id } },
update: {},
create: { userProfileId: viewerId, recipeId: id },
});
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/** Unfavorites a recipe for `viewerId` — idempotent, no error if it wasn't favorited (or doesn't exist/isn't visible: unfavoriting is always safe, nothing to leak). */
export async function removeFavorite(id: number, viewerId: number): Promise<void> {
try {
await prisma.recipeFavorite.deleteMany({
where: { userProfileId: viewerId, recipeId: id },
});
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
await prisma.recipeFavorite.deleteMany({ where: { userProfileId: viewerId, recipeId: id } });
}
/**
@ -780,19 +581,14 @@ export async function assertRecipeVisible(
viewerId: number,
viewerHouseId: number | null,
): Promise<void> {
try {
const recipe = await findRecipeOrThrow(id, viewerId);
if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/** Re-fetches a recipe by id (with {@link recipeInclude}), or throws `404 RECIPE_NOT_FOUND` — the shared "load or reject" step for every recipe endpoint. Does *not* check visibility on its own — callers combine it with {@link canView} (read paths) or {@link assertIsAuthor} (write paths). */
async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWithDetails> {
try {
const recipe = await prisma.recipe.findUnique({
where: { id },
include: recipeInclude(viewerId),
@ -801,9 +597,6 @@ async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWi
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
return recipe;
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/** Shared "load, check visible, check authored by viewer" guard for the write paths (`updateRecipe`/`deleteRecipe`). */
@ -812,7 +605,6 @@ async function assertIsAuthor(
viewerId: number,
viewerHouseId: number | null,
): Promise<void> {
try {
const recipe = await findRecipeOrThrow(id, viewerId);
if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
@ -820,14 +612,10 @@ async function assertIsAuthor(
if (recipe.authorId !== viewerId) {
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/** Throws `404 INGREDIENT_NOT_FOUND` if any of `ingredientIds` doesn't match a reference `Ingredient` row. */
async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(ingredientIds)];
const found = await prisma.ingredient.findMany({
where: { id: { in: uniqueIds } },
@ -842,14 +630,10 @@ async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
`Ingredient(s) not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/** Throws `404 UNIT_NOT_FOUND` if any of `unitIds` doesn't match a reference `Unit` row. */
async function assertUnitsExist(unitIds: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(unitIds)];
const found = await prisma.unit.findMany({
where: { id: { in: uniqueIds } },
@ -858,20 +642,12 @@ async function assertUnitsExist(unitIds: number[]): Promise<void> {
if (found.length !== uniqueIds.length) {
const foundIds = new Set(found.map((unit) => unit.id));
const missing = uniqueIds.filter((id) => !foundIds.has(id));
throw new HttpError(
404,
ErrorCode.UNIT_NOT_FOUND,
`Unit(s) not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw new HttpError(404, ErrorCode.UNIT_NOT_FOUND, `Unit(s) not found: ${missing.join(", ")}`);
}
}
/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */
async function assertDietsExist(dietIds: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(dietIds)];
if (uniqueIds.length === 0) return;
const found = await prisma.diet.findMany({
@ -881,13 +657,6 @@ async function assertDietsExist(dietIds: number[]): Promise<void> {
if (found.length !== uniqueIds.length) {
const foundIds = new Set(found.map((diet) => diet.id));
const missing = uniqueIds.filter((id) => !foundIds.has(id));
throw new HttpError(
404,
ErrorCode.DIET_NOT_FOUND,
`Diet(s) not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `Diet(s) not found: ${missing.join(", ")}`);
}
}

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";
@ -18,15 +17,7 @@ import { prisma } from "../../db/prisma.js";
* client-side (`apps/web`'s `locales/fr/translation.json`).
*/
export async function getDiets(): Promise<DietView[]> {
try {
return await prisma.diet.findMany({ orderBy: { key: "asc" } });
} 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 `async` body without a
// try/catch per the repo's convention.
throw err;
}
return prisma.diet.findMany({ orderBy: { key: "asc" } });
}
/**
@ -37,7 +28,6 @@ export async function getDiets(): Promise<DietView[]> {
* callers.
*/
export async function getAllergies(): Promise<AllergyView[]> {
try {
const allergies = await prisma.allergy.findMany({
include: { category: { select: { key: true, kind: true } } },
orderBy: { category: { key: "asc" } },
@ -47,9 +37,6 @@ export async function getAllergies(): Promise<AllergyView[]> {
key: allergy.category.key,
kind: allergy.category.kind,
}));
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
@ -60,7 +47,6 @@ export async function getAllergies(): Promise<AllergyView[]> {
* `RecipeIngredient.quantity`.
*/
export async function getUnits(): Promise<UnitView[]> {
try {
const units = await prisma.unit.findMany({ orderBy: { key: "asc" } });
return units.map((unit) => ({
id: unit.id,
@ -68,9 +54,6 @@ export async function getUnits(): Promise<UnitView[]> {
type: unit.type,
toBaseFactor: Number(unit.toBaseFactor),
}));
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
@ -79,24 +62,7 @@ export async function getUnits(): Promise<UnitView[]> {
* `TECH_STEPS`). Not consumed by the recipe UI yet see {@link TechStepView}.
*/
export async function getTechSteps(): Promise<TechStepView[]> {
try {
return await prisma.techStep.findMany({ orderBy: { key: "asc" } });
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
* 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
}
return prisma.techStep.findMany({ orderBy: { key: "asc" } });
}
/**
@ -108,23 +74,13 @@ export async function getUtensils(): Promise<UtensilView[]> {
* `recipe-source-sync.ts`'s `syncRecipeSources`).
*/
export async function getSources(): Promise<SourceView[]> {
try {
// Explicit `select` — `url` exists on the `Source` row but isn't part of
// `SourceView` yet, so it must not leak into the response the way a bare
// `findMany()` would let it.
return await prisma.source.findMany({
select: {
id: true,
key: true,
name: true,
official: true,
iconUrl: true,
},
return prisma.source.findMany({
select: { id: true, key: true, name: true, official: true, iconUrl: true },
orderBy: { name: "asc" },
});
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
@ -135,7 +91,6 @@ export async function getSources(): Promise<SourceView[]> {
* allergen/diet come back with `allergens: []`/`diets: []`.
*/
export async function getIngredients(): Promise<IngredientView[]> {
try {
const ingredients = await prisma.ingredient.findMany({
include: {
allergies: { include: { allergy: { include: { category: true } } } },
@ -155,12 +110,6 @@ export async function getIngredients(): Promise<IngredientView[]> {
key: allergy.category.key,
kind: allergy.category.kind,
})),
diets: ingredient.diets.map(({ diet }) => ({
id: diet.id,
key: diet.key,
})),
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })),
}));
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}

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

@ -1,6 +1,6 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { browseSourceSchema, createRecipeSchema, ErrorCode } from "@batch-cooking/shared";
import { ErrorCode, browseSourceSchema, createRecipeSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { browseSource, importSourceItem, previewSourceItem } from "./sources.service.js";

View file

@ -11,23 +11,19 @@ import {
import { prisma } from "../../db/prisma.js";
import { findImportedRecipeIds } from "../../db/recipe-source-sync.js";
import {
type IngredientMatchEntry,
type UnitMatchEntry,
loadIngredientCatalog,
loadUnitCatalog,
} from "../../lib/recipe-matching/ingredient-matcher.js";
import {
mergeDuplicateIngredients,
translateRecipeIngredients,
} from "../../lib/recipe-matching/recipe-translation.js";
import { techStepClassifier } from "../../lib/recipe-matching/tech-step-matcher.js";
import {
markAlreadyImported,
type RecipeSourceAdapter,
} from "../../lib/recipe-sources/recipe-source-adapter.js";
import { RecipeSourceError } from "../../lib/recipe-sources/recipe-source-errors.js";
import { getRecipeSource } from "../../lib/recipe-sources/recipe-source-registry.js";
} from "../../lib/ingredient-matcher.js";
import { type RecipeSourceAdapter, markAlreadyImported } from "../../lib/recipe-source-adapter.js";
import { RecipeSourceError } from "../../lib/recipe-source-errors.js";
import { getRecipeSource } from "../../lib/recipe-source-registry.js";
import { translateRecipeIngredients } from "../../lib/recipe-translation.js";
import { loadTechStepMappingRules, matchTechStepSpans } from "../../lib/tech-step-matcher.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
@ -59,11 +55,8 @@ async function assertSourceEnabled(
houseId: number | null,
sourceKey: string,
): Promise<{ adapter: RecipeSourceAdapter; sourceId: number }> {
try {
const enabledSourceIds = await getHouseSourceIds(houseId);
const source = await prisma.source.findUnique({
where: { key: sourceKey },
});
const source = await prisma.source.findUnique({ where: { key: sourceKey } });
if (!source || !enabledSourceIds.includes(source.id)) {
throw new HttpError(
404,
@ -80,13 +73,6 @@ async function assertSourceEnabled(
);
}
return { adapter, sourceId: source.id };
} 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;
}
}
/**
@ -102,12 +88,8 @@ export async function browseSource(
houseId: number | null,
params: { query?: string; cursor?: string },
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
try {
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
const result = await adapter.list({
query: params.query,
cursor: params.cursor,
});
const result = await adapter.list({ query: params.query, cursor: params.cursor });
const importedRecipeIds = await findImportedRecipeIds(
prisma,
@ -127,9 +109,6 @@ export async function browseSource(
})),
nextCursor: result.nextCursor,
};
} catch (err) {
throw err; // see assertSourceEnabled()'s catch comment above
}
}
/**
@ -139,13 +118,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.
@ -156,7 +132,6 @@ export async function previewSourceItem(
externalId: string,
houseId: number | null,
): Promise<RecipeImportDraftView> {
try {
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
let parsed: ReturnType<typeof adapter.parse>;
@ -170,16 +145,10 @@ export async function previewSourceItem(
throw err;
}
const [stepsWithTechStepMatches, ingredientCatalog, unitCatalog, techStepsByKey] =
await Promise.all([
Promise.all(
parsed.steps.map(async (step) => ({
step,
matches: await techStepClassifier.matchTechStepSpans(step.description, adapter.locale),
})),
),
loadIngredientCatalog(adapter.locale),
loadUnitCatalog(adapter.locale),
const [techStepMappings, ingredientCatalog, unitCatalog, techStepsByKey] = await Promise.all([
loadTechStepMappingRules(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,26 +157,12 @@ 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
// catalog ingredient. Folded into one line per ingredient (quantities
// summed where that's safe) before the draft ever reaches the review
// screen, rather than surfacing the recipe with two rows for "Œuf" and
// making the person sort it out — see issue #53's follow-up.
const mergedIngredients = mergeDuplicateIngredients(translatedIngredients, unitViews);
const ingredients: DraftRecipeIngredientView[] = mergedIngredients.map((ingredient) => ({
const ingredients: DraftRecipeIngredientView[] = translatedIngredients.map((ingredient) => ({
rawText: ingredient.rawText,
quantity: ingredient.quantity,
ingredient:
@ -217,52 +172,12 @@ export async function previewSourceItem(
unit: ingredient.unitId !== null ? (unitById.get(ingredient.unitId) ?? null) : null,
}));
const steps: DraftRecipeStepView[] = stepsWithTechStepMatches.map(({ step, matches }) => ({
const steps: DraftRecipeStepView[] = parsed.steps.map((step) => ({
description: step.description,
picture: step.picture,
techSteps: matches.flatMap((match) => {
techSteps: matchTechStepSpans(step.description, techStepMappings).flatMap((match) => {
const techStep = techStepById.get(match.techStepId);
return techStep
? [
{
techStep,
start: match.start,
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 }]
: [];
}),
},
]
: [];
return techStep ? [{ techStep, start: match.start, end: match.end }] : [];
}),
}));
@ -277,9 +192,6 @@ export async function previewSourceItem(
ingredients,
steps,
};
} catch (err) {
throw err; // see assertSourceEnabled()'s catch comment above
}
}
/**
@ -309,7 +221,6 @@ export async function importSourceItem(
authorId: number,
authorHouseId: number | null,
): Promise<RecipeView> {
try {
const { adapter, sourceId } = await assertSourceEnabled(authorHouseId, sourceKey);
const alreadyImported = await findImportedRecipeIds(prisma, sourceKey, [externalId]);
@ -321,12 +232,9 @@ export async function importSourceItem(
);
}
return await createImportedRecipe(input, authorId, authorHouseId, {
return createImportedRecipe(input, authorId, authorHouseId, {
sourceId,
externalId,
locale: adapter.locale,
});
} catch (err) {
throw err; // see assertSourceEnabled()'s catch comment above
}
}

View file

@ -1,123 +0,0 @@
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
* 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).
*
* 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.
*
* `"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.
*
* 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.
*/
export async function backfillTechSteps(): Promise<{ total: number; changed: number }> {
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);
});
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);
});
}

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

@ -1,7 +1,5 @@
import { createServer } from "./app.js";
import { env } from "./config/env.js";
import { logger } from "./lib/logger.service.js";
import { techStepClassifier } from "./lib/recipe-matching/tech-step-matcher.js";
import { registerAllRecipeSources } from "./sources/index.js";
// Populates the recipe-source registry (recipe-source-registry.ts) before
@ -9,47 +7,8 @@ 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));
}
}
}
await warmUpTechStepClassifier();
const server = createServer();
server.listen(env.PORT, () => {
logger.info("API listening", { port: env.PORT, nodeEnv: env.NODE_ENV });
console.log(`API listening on http://localhost:${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 { registerRecipeSource } from "../lib/recipe-source-registry.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

@ -2,11 +2,8 @@ import type {
ParsedRecipe,
ParsedRecipeIngredient,
RecipeSourceAdapter,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
} from "../lib/recipe-source-adapter.js";
import { RecipeSourceFetchError, RecipeSourceParseError } from "../lib/recipe-source-errors.js";
const SOURCE_KEY = "jsonLdRecipe";
@ -44,17 +41,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,22 +164,8 @@ 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;
url: string;
}> = {
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
key: SOURCE_KEY,
name: "Import générique (JSON-LD)",
official: false,
@ -205,19 +179,10 @@ export const jsonLdRecipeAdapter: RecipeSourceAdapter<{
locale: "fr",
async list() {
try {
return { items: [], nextCursor: null };
} catch (err) {
// Rethrown as-is — 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.
throw err;
}
},
async fetchDetail(url: string): Promise<{ html: string; url: string }> {
try {
let response: Response;
try {
response = await fetch(url);
@ -229,9 +194,6 @@ export const jsonLdRecipeAdapter: RecipeSourceAdapter<{
}
const html = await response.text();
return { html, url };
} catch (err) {
throw err; // see list()'s catch comment above
}
},
parse({ html, url }): ParsedRecipe {

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

View file

@ -3,11 +3,8 @@ import type {
RecipeSourceAdapter,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
} from "../lib/recipe-source-adapter.js";
import { RecipeSourceFetchError, RecipeSourceParseError } from "../lib/recipe-source-errors.js";
const SOURCE_KEY = "theMealDb";
@ -38,7 +35,6 @@ interface TheMealDbMealsResponse {
}
async function fetchTheMealDb<T>(path: string): Promise<T> {
try {
let response: Response;
try {
response = await fetch(`${API_BASE}${path}`);
@ -53,13 +49,7 @@ async function fetchTheMealDb<T>(path: string): Promise<T> {
`TheMealDB responded ${response.status} (${path})`,
);
}
return (await response.json()) as T;
} catch (err) {
// Rethrown as-is — this adapter's only caller (`sources.service.ts`)
// already handles/logs failures centrally; this method just isn't
// allowed a bare `await` per the repo's async/try-catch convention.
throw err;
}
return response.json() as Promise<T>;
}
function detailUrl(idMeal: string): string {
@ -92,7 +82,6 @@ export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
locale: "en",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
const query = params.query ?? "";
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
`/search.php?s=${encodeURIComponent(query)}`,
@ -109,22 +98,15 @@ export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
})),
nextCursor: null,
};
} catch (err) {
throw err; // see fetchTheMealDb()'s catch comment above
}
},
async fetchDetail(externalId: string): Promise<TheMealDbMeal> {
try {
const data = await fetchTheMealDb<TheMealDbMealsResponse>(`/lookup.php?i=${externalId}`);
const meal = data.meals?.[0];
if (!meal) {
throw new RecipeSourceFetchError(SOURCE_KEY, `No meal found for id "${externalId}"`);
}
return meal;
} catch (err) {
throw err; // see fetchTheMealDb()'s catch comment above
}
},
parse(meal: TheMealDbMeal): ParsedRecipe {
@ -147,17 +129,10 @@ export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
// Free-text instructions, usually one step per line — splitting on
// blank/newlines is the closest this source gets to discrete steps.
// TheMealDB frequently numbers each step on its own line ahead of the
// paragraph that follows (e.g. "…melted.\n\n2\n\nPreheat oven…") rather
// than inline ("1. Preheat oven…") — the blank-line split above turns
// that lone number into its own "line", which would otherwise become a
// bogus step containing nothing but a digit. Drop those rather than
// keep them as steps in their own right (see issue #52).
const steps = (meal.strInstructions ?? "")
.split(/\r?\n+/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.filter((line) => !/^\d+\.?$/.test(line))
.map((description) => ({ description, picture: null }));
if (steps.length === 0) {
throw new RecipeSourceParseError(

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

@ -45,7 +45,7 @@ export async function resetDatabase() {
TRUNCATE TABLE
"user_profile_allergy", "user_preference", "allergy", "category",
"planning_item", "planning",
"recipe_ingredient", "step_tech_step", "step", "tech_step",
"recipe_ingredient", "step_tech_step", "step", "tech_step_mapping", "tech_step",
"recipe", "ingredients", "sources", "unit",
"user_profiles", "diet", "house"
RESTART IDENTITY CASCADE;

View file

@ -6,11 +6,8 @@ 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 type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
import {
clearRecipeSources,
registerRecipeSource,
} from "../src/lib/recipe-sources/recipe-source-registry.js";
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
import { resetDatabase } from "../test-support/reset-db.js";
function buildSignupPayload(): SignupInput {

View file

@ -0,0 +1,218 @@
import { expect } from "chai";
import { prisma } from "../src/db/prisma.js";
import {
type IngredientMatchEntry,
type UnitMatchEntry,
extractQuantity,
loadIngredientCatalog,
loadUnitCatalog,
matchIngredientName,
matchUnit,
} from "../src/lib/ingredient-matcher.js";
import { resetDatabase } from "../test-support/reset-db.js";
describe("ingredient-matcher", () => {
describe("matchIngredientName", () => {
const tomato: IngredientMatchEntry = { ingredientId: 1, label: "Tomato" };
const chicken: IngredientMatchEntry = { ingredientId: 2, label: "Chicken" };
const chickenBreast: IngredientMatchEntry = { ingredientId: 3, label: "Chicken breast" };
const onion: IngredientMatchEntry = { ingredientId: 4, label: "Onion" };
const allPurposeFlour: IngredientMatchEntry = { ingredientId: 5, label: "All-purpose flour" };
const catalog = [tomato, chicken, chickenBreast, onion, allPurposeFlour];
it("matches an exact single-word label", () => {
expect(matchIngredientName("tomato", catalog)).to.equal(tomato.ingredientId);
});
it("is case- and accent-insensitive", () => {
expect(matchIngredientName("TOMATO", catalog)).to.equal(tomato.ingredientId);
expect(matchIngredientName("Tömato", catalog)).to.equal(tomato.ingredientId);
});
it("tolerates a regular plural", () => {
expect(matchIngredientName("tomatoes", catalog)).to.equal(tomato.ingredientId);
expect(matchIngredientName("onions", catalog)).to.equal(onion.ingredientId);
});
it("tolerates extra descriptive words around the match", () => {
expect(matchIngredientName("2 large diced yellow onions", catalog)).to.equal(
onion.ingredientId,
);
});
it("prefers the more specific multi-word label over a shorter one it contains", () => {
expect(matchIngredientName("boneless skinless chicken breasts", catalog)).to.equal(
chickenBreast.ingredientId,
);
});
it("still matches the shorter label when the more specific one isn't mentioned", () => {
expect(matchIngredientName("diced chicken thighs", catalog)).to.equal(chicken.ingredientId);
});
it("matches a hyphenated multi-word label", () => {
expect(matchIngredientName("2 cups all-purpose flour", catalog)).to.equal(
allPurposeFlour.ingredientId,
);
});
it("doesn't false-positive a short label inside an unrelated longer word", () => {
// "egg" must not match inside "eggplant" — whole-token comparison, not substring.
const eggplant: IngredientMatchEntry = { ingredientId: 6, label: "Eggplant" };
const egg: IngredientMatchEntry = { ingredientId: 7, label: "Egg" };
expect(matchIngredientName("eggplant", [egg, eggplant])).to.equal(eggplant.ingredientId);
});
it("returns null when nothing matches", () => {
expect(matchIngredientName("mango", catalog)).to.equal(null);
});
it("returns null for an empty catalog", () => {
expect(matchIngredientName("tomato", [])).to.equal(null);
});
it("returns null for an empty name", () => {
expect(matchIngredientName("", catalog)).to.equal(null);
});
it("breaks a same-specificity tie by the lowest ingredientId", () => {
const onionA: IngredientMatchEntry = { ingredientId: 20, label: "Onion" };
const onionB: IngredientMatchEntry = { ingredientId: 21, label: "Onion" };
expect(matchIngredientName("onion", [onionB, onionA])).to.equal(20);
});
});
describe("matchUnit", () => {
const gram: UnitMatchEntry = { unitId: 1, synonyms: ["g", "gram", "grams"] };
const tablespoon: UnitMatchEntry = {
unitId: 2,
synonyms: ["tbsp", "tbs", "tablespoon", "tablespoons"],
};
const cup: UnitMatchEntry = { unitId: 3, synonyms: ["cup", "cups"] };
const catalog = [gram, tablespoon, cup];
it("matches a full word synonym", () => {
expect(matchUnit("tablespoon", catalog)).to.equal(tablespoon.unitId);
});
it("matches an abbreviation synonym", () => {
expect(matchUnit("tbsp", catalog)).to.equal(tablespoon.unitId);
});
it("matches a plural synonym via the same stemming as ingredients", () => {
expect(matchUnit("cups", catalog)).to.equal(cup.unitId);
});
it("is case-insensitive", () => {
expect(matchUnit("TBSP", catalog)).to.equal(tablespoon.unitId);
});
it("only looks at the first word — ignores trailing text", () => {
expect(matchUnit("cup flour", catalog)).to.equal(cup.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);
});
it("returns null when nothing matches", () => {
expect(matchUnit("pound", catalog)).to.equal(null);
});
it("returns null for an empty catalog", () => {
expect(matchUnit("cup", [])).to.equal(null);
});
it("returns null for an empty string", () => {
expect(matchUnit("", catalog)).to.equal(null);
});
});
describe("extractQuantity", () => {
it("extracts a plain integer", () => {
expect(extractQuantity("2 onions")).to.deep.equal({ quantity: 2, remainder: "onions" });
});
it("extracts a decimal using a dot", () => {
expect(extractQuantity("1.5 cups flour")).to.deep.equal({
quantity: 1.5,
remainder: "cups flour",
});
});
it("extracts a decimal using a comma", () => {
expect(extractQuantity("1,5 cups flour")).to.deep.equal({
quantity: 1.5,
remainder: "cups flour",
});
});
it("extracts a simple fraction", () => {
expect(extractQuantity("1/2 cup sugar")).to.deep.equal({
quantity: 0.5,
remainder: "cup sugar",
});
});
it("extracts a mixed number", () => {
expect(extractQuantity("1 1/2 cups sugar")).to.deep.equal({
quantity: 1.5,
remainder: "cups sugar",
});
});
it("returns null quantity and the trimmed original text when there's no leading number", () => {
expect(extractQuantity("salt to taste")).to.deep.equal({
quantity: null,
remainder: "salt to taste",
});
});
it("trims surrounding whitespace", () => {
expect(extractQuantity(" 2 eggs ")).to.deep.equal({ quantity: 2, remainder: "eggs" });
});
it("only takes the first number of a hyphenated range", () => {
expect(extractQuantity("2-3 carrots")).to.deep.equal({
quantity: 2,
remainder: "-3 carrots",
});
});
});
describe("loadIngredientCatalog / loadUnitCatalog", () => {
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
it("loads one entry per Ingredient that has an English label, keyed by real ingredientId", async () => {
const tomato = await prisma.ingredient.findFirstOrThrow({ where: { key: "tomato" } });
const ingredientCount = await prisma.ingredient.count();
const catalog = await loadIngredientCatalog();
// Every seeded ingredient has an authored English label (verified at
// generation time — see packages/shared/src/data/catalog-labels-en.ts),
// so nothing should be silently skipped.
expect(catalog).to.have.length(ingredientCount);
const tomatoEntry = catalog.find((entry) => entry.ingredientId === tomato.id);
expect(tomatoEntry?.label).to.equal("Tomato");
});
it("loads one entry per Unit that has English 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();
expect(catalog).to.have.length(unitCount);
const cupEntry = catalog.find((entry) => entry.unitId === cup.id);
expect(cupEntry?.synonyms).to.deep.equal(["cup", "cups"]);
});
});
});

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,6 @@
import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { jsonLdRecipeAdapter } from "../../src/sources/json-ld-recipe.js";
import { RecipeSourceFetchError, RecipeSourceParseError } from "../src/lib/recipe-source-errors.js";
import { jsonLdRecipeAdapter } from "../src/sources/json-ld-recipe.js";
/** Stubs `globalThis.fetch` to return `html` as the response body — same reasoning/pattern as `the-meal-db.test.ts`'s `stubFetch`, just returning text instead of JSON. */
function stubFetchHtml(html: string, status = 200) {

View file

@ -1,105 +0,0 @@
import { expect } from "chai";
import { LoggerService, logger, minLevelFor } from "../src/lib/logger.service.js";
/**
* Stubs one `console` method for one test, capturing every call instead of
* actually writing to stdout/stderr same "stub the one thing that
* touches the outside world" approach `the-meal-db.test.ts` uses for
* `fetch`. Restored by the caller (`afterEach` below) regardless of which
* test used it.
*/
function stubConsoleMethod(method: "debug" | "info" | "warn" | "error") {
const calls: unknown[][] = [];
// biome-ignore lint/suspicious/noConsole: this *is* the console-stubbing helper — it has to read the real method to be able to restore it. Never calls it for real.
const original = console[method];
console[method] = (...args: unknown[]) => {
calls.push(args);
};
return {
calls,
restore: () => (console[method] = original),
};
}
describe("logger.service", () => {
describe("minLevelFor", () => {
it("only lets warn/error through in test — Mocha's own output shouldn't get any noisier", () => {
expect(minLevelFor("test")).to.equal("warn");
});
it("only lets info/warn/error through in production — debug is too verbose to ship", () => {
expect(minLevelFor("production")).to.equal("info");
});
it("lets everything through, including debug, in development", () => {
expect(minLevelFor("development")).to.equal("debug");
});
});
describe("logger", () => {
let stub: ReturnType<typeof stubConsoleMethod>;
afterEach(() => {
stub?.restore();
});
it("emits a warn line as a single JSON object via console.warn, with a timestamp/level/message and any extra meta merged in", () => {
stub = stubConsoleMethod("warn");
logger.warn("Something routine failed", { status: 404, code: 4049 });
expect(stub.calls).to.have.length(1);
const [line] = stub.calls[0];
const parsed = JSON.parse(line as string);
expect(parsed.level).to.equal("warn");
expect(parsed.message).to.equal("Something routine failed");
expect(parsed.status).to.equal(404);
expect(parsed.code).to.equal(4049);
expect(new Date(parsed.timestamp).toString()).to.not.equal("Invalid Date");
});
it("emits an error line via console.error", () => {
stub = stubConsoleMethod("error");
logger.error("Something broke");
expect(stub.calls).to.have.length(1);
const parsed = JSON.parse(stub.calls[0][0] as string);
expect(parsed.level).to.equal("error");
});
it('drops debug/info under the test suite\'s own NODE_ENV=test (minLevelFor("test") === "warn")', () => {
const debugStub = stubConsoleMethod("debug");
const infoStub = stubConsoleMethod("info");
logger.debug("Should not appear");
logger.info("Should not appear either");
expect(debugStub.calls).to.have.length(0);
expect(infoStub.calls).to.have.length(0);
debugStub.restore();
infoStub.restore();
});
it("never lets meta override the line's own timestamp/level/message keys", () => {
stub = stubConsoleMethod("warn");
logger.warn("Real message", { message: "spoofed", level: "spoofed", timestamp: "spoofed" });
const parsed = JSON.parse(stub.calls[0][0] as string);
expect(parsed.message).to.equal("Real message");
expect(parsed.level).to.equal("warn");
expect(new Date(parsed.timestamp).toString()).to.not.equal("Invalid Date");
});
it('a separate instance constructed with nodeEnv="development" lets debug through, independently of the shared logger\'s own NODE_ENV=test threshold', () => {
const debugStub = stubConsoleMethod("debug");
const devLogger = new LoggerService("development");
devLogger.debug("Visible in dev");
expect(debugStub.calls).to.have.length(1);
debugStub.restore();
});
});
});

View file

@ -1,459 +0,0 @@
import { INGREDIENT_LABEL_SYNONYMS_EN, INGREDIENT_LABEL_SYNONYMS_FR } from "@batch-cooking/shared";
import { expect } from "chai";
import { prisma } from "../../src/db/prisma.js";
import {
extractQuantity,
findIngredientMentions,
type IngredientMatchEntry,
loadIngredientCatalog,
loadUnitCatalog,
matchIngredientName,
matchUnit,
type UnitMatchEntry,
} from "../../src/lib/recipe-matching/ingredient-matcher.js";
import { resetDatabase } from "../../test-support/reset-db.js";
describe("ingredient-matcher", () => {
describe("matchIngredientName", () => {
const tomato: IngredientMatchEntry = { ingredientId: 1, label: "Tomato" };
const chicken: IngredientMatchEntry = { ingredientId: 2, label: "Chicken" };
const chickenBreast: IngredientMatchEntry = { ingredientId: 3, label: "Chicken breast" };
const onion: IngredientMatchEntry = { ingredientId: 4, label: "Onion" };
const allPurposeFlour: IngredientMatchEntry = { ingredientId: 5, label: "All-purpose flour" };
const catalog = [tomato, chicken, chickenBreast, onion, allPurposeFlour];
it("matches an exact single-word label", () => {
expect(matchIngredientName("tomato", catalog)).to.equal(tomato.ingredientId);
});
it("is case- and accent-insensitive", () => {
expect(matchIngredientName("TOMATO", catalog)).to.equal(tomato.ingredientId);
expect(matchIngredientName("Tömato", catalog)).to.equal(tomato.ingredientId);
});
it("tolerates a regular plural", () => {
expect(matchIngredientName("tomatoes", catalog)).to.equal(tomato.ingredientId);
expect(matchIngredientName("onions", catalog)).to.equal(onion.ingredientId);
});
it("tolerates extra descriptive words around the match", () => {
expect(matchIngredientName("2 large diced yellow onions", catalog)).to.equal(
onion.ingredientId,
);
});
it("prefers the more specific multi-word label over a shorter one it contains", () => {
expect(matchIngredientName("boneless skinless chicken breasts", catalog)).to.equal(
chickenBreast.ingredientId,
);
});
it("still matches the shorter label when the more specific one isn't mentioned", () => {
expect(matchIngredientName("diced chicken thighs", catalog)).to.equal(chicken.ingredientId);
});
it("matches a hyphenated multi-word label", () => {
expect(matchIngredientName("2 cups all-purpose flour", catalog)).to.equal(
allPurposeFlour.ingredientId,
);
});
it("doesn't false-positive a short label inside an unrelated longer word", () => {
// "egg" must not match inside "eggplant" — whole-token comparison, not substring.
const eggplant: IngredientMatchEntry = { ingredientId: 6, label: "Eggplant" };
const egg: IngredientMatchEntry = { ingredientId: 7, label: "Egg" };
expect(matchIngredientName("eggplant", [egg, eggplant])).to.equal(eggplant.ingredientId);
});
it("returns null when nothing matches", () => {
expect(matchIngredientName("mango", catalog)).to.equal(null);
});
it("returns null for an empty catalog", () => {
expect(matchIngredientName("tomato", [])).to.equal(null);
});
it("returns null for an empty name", () => {
expect(matchIngredientName("", catalog)).to.equal(null);
});
it("matches an alternate wording of the same ingredient via a second catalog entry sharing its ingredientId (issue #54)", () => {
const vanillaBean: IngredientMatchEntry = { ingredientId: 8, label: "Vanilla bean" };
const vanillaBeanSynonym: IngredientMatchEntry = { ingredientId: 8, label: "Vanilla pod" };
const synonymCatalog = [vanillaBean, vanillaBeanSynonym];
expect(matchIngredientName("1 vanilla pod", synonymCatalog)).to.equal(8);
expect(matchIngredientName("1 vanilla bean", synonymCatalog)).to.equal(8);
});
it("breaks a same-specificity tie by the lowest ingredientId", () => {
const onionA: IngredientMatchEntry = { ingredientId: 20, label: "Onion" };
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", () => {
const gram: UnitMatchEntry = { unitId: 1, synonyms: ["g", "gram", "grams"] };
const tablespoon: UnitMatchEntry = {
unitId: 2,
synonyms: ["tbsp", "tbs", "tablespoon", "tablespoons"],
};
const cup: UnitMatchEntry = { unitId: 3, synonyms: ["cup", "cups"] };
const catalog = [gram, tablespoon, cup];
it("matches a full word synonym", () => {
expect(matchUnit("tablespoon", catalog)).to.equal(tablespoon.unitId);
});
it("matches an abbreviation synonym", () => {
expect(matchUnit("tbsp", catalog)).to.equal(tablespoon.unitId);
});
it("matches a plural synonym via the same stemming as ingredients", () => {
expect(matchUnit("cups", catalog)).to.equal(cup.unitId);
});
it("is case-insensitive", () => {
expect(matchUnit("TBSP", catalog)).to.equal(tablespoon.unitId);
});
it("ignores trailing text after the unit word", () => {
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);
});
it("returns null when nothing matches", () => {
expect(matchUnit("pound", catalog)).to.equal(null);
});
it("returns null for an empty catalog", () => {
expect(matchUnit("cup", [])).to.equal(null);
});
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", () => {
it("extracts a plain integer", () => {
expect(extractQuantity("2 onions")).to.deep.equal({ quantity: 2, remainder: "onions" });
});
it("extracts a decimal using a dot", () => {
expect(extractQuantity("1.5 cups flour")).to.deep.equal({
quantity: 1.5,
remainder: "cups flour",
});
});
it("extracts a decimal using a comma", () => {
expect(extractQuantity("1,5 cups flour")).to.deep.equal({
quantity: 1.5,
remainder: "cups flour",
});
});
it("extracts a simple fraction", () => {
expect(extractQuantity("1/2 cup sugar")).to.deep.equal({
quantity: 0.5,
remainder: "cup sugar",
});
});
it("extracts a mixed number", () => {
expect(extractQuantity("1 1/2 cups sugar")).to.deep.equal({
quantity: 1.5,
remainder: "cups sugar",
});
});
it("returns null quantity and the trimmed original text when there's no leading number", () => {
expect(extractQuantity("salt to taste")).to.deep.equal({
quantity: null,
remainder: "salt to taste",
});
});
it("trims surrounding whitespace", () => {
expect(extractQuantity(" 2 eggs ")).to.deep.equal({ quantity: 2, remainder: "eggs" });
});
it("only takes the first number of a hyphenated range", () => {
expect(extractQuantity("2-3 carrots")).to.deep.equal({
quantity: 2,
remainder: "-3 carrots",
});
});
});
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();
});
after(async () => {
await prisma.$disconnect();
});
it("loads one entry per Ingredient that has an English label, plus one per alternate wording (INGREDIENT_LABEL_SYNONYMS_EN), keyed by real ingredientId", async () => {
const tomato = await prisma.ingredient.findFirstOrThrow({ where: { key: "tomato" } });
const vanillaBean = await prisma.ingredient.findFirstOrThrow({
where: { key: "vanillaBean" },
});
const ingredientCount = await prisma.ingredient.count();
const synonymCount = Object.values(INGREDIENT_LABEL_SYNONYMS_EN).reduce(
(sum, synonyms) => sum + synonyms.length,
0,
);
const catalog = await loadIngredientCatalog();
// Every seeded ingredient has an authored English label (verified at
// generation time — see packages/shared/src/data/catalog-labels-en.ts),
// so nothing should be silently skipped — plus one extra entry per
// synonym (issue #54), sharing the same ingredientId as the primary
// label's entry.
expect(catalog).to.have.length(ingredientCount + synonymCount);
const tomatoEntry = catalog.find((entry) => entry.ingredientId === tomato.id);
expect(tomatoEntry?.label).to.equal("Tomato");
const vanillaBeanEntries = catalog.filter((entry) => entry.ingredientId === vanillaBean.id);
expect(vanillaBeanEntries.map((entry) => entry.label)).to.deep.equal([
"Vanilla bean",
"Vanilla pod",
]);
});
it("loads one entry per Unit that has English 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();
expect(catalog).to.have.length(unitCount);
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

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

@ -1,427 +0,0 @@
import { expect } from "chai";
import { prisma } from "../../src/db/prisma.js";
import {
normalizeText,
splitIntoClauses,
type TechniqueCandidate,
techStepClassifier,
} from "../../src/lib/recipe-matching/tech-step-matcher.js";
import { resetDatabase } from "../../test-support/reset-db.js";
describe("tech-step-matcher", () => {
describe("normalizeText", () => {
it("lowercases and strips accents", () => {
expect(normalizeText("Déglacer AU FOUR")).to.equal("deglacer au four");
});
it("strips a variety of diacritics, including cedilla", () => {
expect(normalizeText("Façon Œuf à l'Étouffée")).to.equal("facon œuf a l'etouffee");
});
it("leaves already-plain text unchanged, aside from casing", () => {
expect(normalizeText("Mix everything")).to.equal("mix everything");
});
it("returns an empty string for an empty input", () => {
expect(normalizeText("")).to.equal("");
});
});
describe("splitIntoClauses", () => {
// A candidate's own `uid` doesn't matter to the splitting logic itself
// (it's opaque, carried through as `anchor`) — kept short and
// arbitrary across these fixtures.
function candidate(uid: string, start: number, end: number): TechniqueCandidate {
return { uid, start, end };
}
it("returns the whole description as one anchor-less clause when there are no candidates", () => {
const text = "Servir immédiatement";
const result = splitIntoClauses(text, []);
expect(result).to.deep.equal([{ start: 0, end: text.length, anchor: null }]);
});
it("returns the whole description as one clause anchored on the single candidate", () => {
const melt = candidate("melt", 6, 13);
const text = "Faire fondre le beurre";
const result = splitIntoClauses(text, [melt]);
expect(result).to.deep.equal([{ start: 0, end: text.length, anchor: melt }]);
});
it("splits into two clauses at the whitespace nearest the gap's midpoint between two candidates", () => {
// "Préchauffer la poêle, puis faire fondre le beurre"
// 0 1 2 3 4
// 0123456789012345678901234567890123456789012345678901
const preheat = candidate("preheat", 0, 11); // "Préchauffer"
const melt = candidate("melt", 27, 39); // "faire fondre"
const text = "Préchauffer la poêle, puis faire fondre le beurre";
const result = splitIntoClauses(text, [preheat, melt]);
expect(result).to.have.length(2);
// The gap between the two candidates is [11, 27) — its raw midpoint
// (19) falls inside "poêle" (see findGapSplitPoint's doc comment for
// why that's specifically what this snaps away from); the nearest
// actual whitespace to that midpoint is the space at 21, right after
// the comma.
expect(result[0]).to.deep.equal({ start: 0, end: 21, anchor: preheat });
expect(result[1]).to.deep.equal({ start: 21, end: text.length, anchor: melt });
// The two clauses are contiguous and cover the whole text.
expect(
text.slice(result[0].start, result[0].end) + text.slice(result[1].start, result[1].end),
).to.equal(text);
});
it("sorts out-of-order candidates before splitting, and anchors each clause on the matching one", () => {
const preheat = candidate("preheat", 0, 11);
const melt = candidate("melt", 27, 39);
// Passed in reverse — the function must still produce clauses in
// reading order, each anchored on the right candidate.
const result = splitIntoClauses("Préchauffer la poêle, puis faire fondre le beurre", [
melt,
preheat,
]);
expect(result.map((clause) => clause.anchor?.uid)).to.deep.equal(["preheat", "melt"]);
});
it("produces N contiguous clauses for N candidates, each anchored on its own", () => {
const a = candidate("a", 0, 3);
const b = candidate("b", 10, 13);
const c = candidate("c", 20, 23);
const text = "x".repeat(30);
const result = splitIntoClauses(text, [a, b, c]);
expect(result).to.have.length(3);
expect(result.map((clause) => clause.anchor?.uid)).to.deep.equal(["a", "b", "c"]);
// Contiguous: each clause's end is the next one's start.
expect(result[0].start).to.equal(0);
expect(result[0].end).to.equal(result[1].start);
expect(result[1].end).to.equal(result[2].start);
expect(result[2].end).to.equal(text.length);
});
it("clamps the split point to the earlier candidate's own end when two candidates are adjacent/overlapping", () => {
// Gap midpoint would fall *before* `a`'s own end here — must not
// produce a clause that cuts into `a`'s own anchor span.
const a = candidate("a", 0, 10);
const b = candidate("b", 8, 15);
const result = splitIntoClauses("x".repeat(20), [a, b]);
expect(result[0].end).to.be.at.least(a.end);
expect(result[1].start).to.equal(result[0].end);
});
});
describe("techStepClassifier", () => {
// `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).
let simmerId: number;
let cookId: number;
let bakeId: number;
let preheatId: number;
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" } }),
]);
simmerId = simmer.id;
cookId = cook.id;
bakeId = bake.id;
preheatId = preheat.id;
meltId = melt.id;
boilId = boil.id;
chopId = chop.id;
panId = pan.id;
butterId = butter.id;
onionId = onion.id;
walnutsId = walnuts.id;
});
after(async () => {
await prisma.$disconnect();
});
describe("matchTechSteps", () => {
it("matches an exact expression", async () => {
expect(
await techStepClassifier.matchTechSteps("Faire mijoter à feu doux", "fr"),
).to.deep.equal([simmerId]);
});
it("is case- and accent-insensitive", async () => {
expect(await techStepClassifier.matchTechSteps("FAIRE MIJOTER", "fr")).to.deep.equal([
simmerId,
]);
});
it("returns an empty sequence when nothing matches", async () => {
expect(
await techStepClassifier.matchTechSteps("Ranger les couverts dans le tiroir", "fr"),
).to.deep.equal([]);
});
it("returns an empty sequence for an empty description", async () => {
expect(await techStepClassifier.matchTechSteps("", "fr")).to.deep.equal([]);
});
it("returns an empty sequence for a locale nothing was trained on", async () => {
expect(
await techStepClassifier.matchTechSteps("Faire mijoter à feu doux", "de"),
).to.deep.equal([]);
});
it("detects several distinct techniques in one step, in reading order", async () => {
expect(
await techStepClassifier.matchTechSteps(
"Préchauffer la poêle, puis faire fondre le beurre",
"fr",
),
).to.deep.equal([preheatId, meltId]);
});
it("reverses the sequence when the techniques are mentioned in the opposite order", async () => {
expect(
await techStepClassifier.matchTechSteps(
"Faire fondre le beurre puis préchauffer le four",
"fr",
),
).to.deep.equal([meltId, preheatId]);
});
it("still matches the generic technique on its own when the more specific one isn't implied", async () => {
expect(
await techStepClassifier.matchTechSteps("Faire cuire à feu moyen", "fr"),
).to.deep.equal([cookId]);
});
it("resolves the more specific technique when a generic one's own vocabulary is embedded in it", async () => {
// "Cuire au four" literally contains "cuire" (the generic `cook`
// verb) but means the more specific `bake` — the classifier (not
// a weight table) is what has to get this right now.
expect(
await techStepClassifier.matchTechSteps("Cuire au four pendant 30 minutes", "fr"),
).to.deep.equal([bakeId]);
});
it("understands a technique described without ever naming it — the whole point of moving off pure keyword matching", async () => {
// No literal "fondre"/"fondu" anywhere in this sentence, yet it
// unambiguously means `melt` — this is the exact motivating case
// (see this module's own doc comment) a regex could never catch.
expect(
await techStepClassifier.matchTechSteps(
"jusqu'à ce que le beurre ait disparu dans la poêle",
"fr",
),
).to.deep.equal([meltId]);
});
it("understands preheating described without the verb 'préchauffer'", async () => {
expect(
await techStepClassifier.matchTechSteps("mettre la poêle sur feu vif", "fr"),
).to.deep.equal([preheatId]);
});
it("matches English text against the English-trained vocabulary", async () => {
expect(
await techStepClassifier.matchTechSteps(
"Bring a large saucepan of salted water to the boil",
"en",
),
).to.deep.equal([boilId]);
});
});
describe("matchTechStepSpans", () => {
it("returns a tight keyword span, and a wider context span that's the whole description when there's only one candidate", async () => {
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: [],
},
]);
expect(text.slice(6, 13).toLowerCase()).to.equal("mijoter");
});
it("returns an empty list when nothing matches", async () => {
expect(
await techStepClassifier.matchTechStepSpans("Ranger les couverts dans le tiroir", "fr"),
).to.deep.equal([]);
});
it("returns each distinct technique's own tight keyword span and its own wider context span, in reading order", async () => {
const text = "Préchauffer la poêle, puis faire fondre le beurre";
const result = await techStepClassifier.matchTechStepSpans(text, "fr");
expect(result).to.have.length(2);
expect(result[0].techStepId).to.equal(preheatId);
expect(result[1].techStepId).to.equal(meltId);
// Each keyword span, sliced back out of the original text, is
// exactly the word(s) that anchored that match — what the frontend
// needs to highlight the exact right characters.
expect(text.slice(result[0].start, result[0].end).toLowerCase()).to.equal("préchauffer");
expect(text.slice(result[1].start, result[1].end).toLowerCase()).to.equal("faire fondre");
// Each context span is the wider clause the keyword was found in —
// the two are contiguous and cover the whole description between
// them (see splitIntoClauses, which computed these).
expect(text.slice(result[0].contextStart, result[0].contextEnd)).to.equal(
"Préchauffer la poêle,",
);
expect(text.slice(result[1].contextStart, result[1].contextEnd)).to.equal(
" puis faire fondre le beurre",
);
expect(result[0].contextEnd).to.equal(result[1].contextStart);
});
it("understands both techniques in the classic 'Dans une poêle chaude, faire chauffer une noix de beurre' example, each with its own keyword and context", async () => {
// The motivating example for context spans in the first place:
// `preheat`'s keyword is a noun phrase ("poêle chaude"), not a
// verb — its context ("Dans une poêle chaude") is what actually
// shows this is about preparing the pan, not (say) deglazing one.
const text = "Dans une poêle chaude, faire chauffer une noix de beurre";
const result = await techStepClassifier.matchTechStepSpans(text, "fr");
expect(result).to.have.length(2);
expect(result[0]).to.deep.equal({
techStepId: preheatId,
start: 9,
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,
start: 23,
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(
"Dans une poêle chaude,",
);
expect(text.slice(result[1].start, result[1].end)).to.equal("faire chauffer");
expect(text.slice(result[1].contextStart, result[1].contextEnd)).to.equal(
" faire chauffer une noix de beurre",
);
});
it("falls back to highlighting the whole clause for both spans when a technique was found with no literal anchor word", async () => {
const text = "jusqu'à ce que le beurre ait disparu dans la poêle";
const result = await techStepClassifier.matchTechStepSpans(text, "fr");
expect(result).to.deep.equal([
{
techStepId: meltId,
start: 0,
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 }],
},
]);
});
it("chop matches English text against the English-trained vocabulary, tight keyword span", async () => {
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: [],
},
]);
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

@ -2,15 +2,12 @@ import 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 { findImportedRecipeIds, syncRecipeSources } from "../../src/db/recipe-source-sync.js";
import type { RecipeSourceAdapter } from "../../src/lib/recipe-sources/recipe-source-adapter.js";
import {
clearRecipeSources,
registerRecipeSource,
} from "../../src/lib/recipe-sources/recipe-source-registry.js";
import { resetDatabase } from "../../test-support/reset-db.js";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { findImportedRecipeIds, syncRecipeSources } from "../src/db/recipe-source-sync.js";
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `recipe.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
function buildSignupPayload(): SignupInput {

View file

@ -5,19 +5,19 @@ import type {
RecipeSourceListItem,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../../src/lib/recipe-sources/recipe-source-adapter.js";
import { markAlreadyImported } from "../../src/lib/recipe-sources/recipe-source-adapter.js";
} from "../src/lib/recipe-source-adapter.js";
import { markAlreadyImported } from "../src/lib/recipe-source-adapter.js";
import {
RecipeSourceError,
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
} from "../src/lib/recipe-source-errors.js";
import {
clearRecipeSources,
getRecipeSource,
listRecipeSources,
registerRecipeSource,
} from "../../src/lib/recipe-sources/recipe-source-registry.js";
} from "../src/lib/recipe-source-registry.js";
interface FakeRawRecipe {
externalId: string;

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,22 +1,14 @@
import { expect } from "chai";
import { prisma } from "../../src/db/prisma.js";
import type {
IngredientMatchEntry,
UnitMatchEntry,
} from "../../src/lib/recipe-matching/ingredient-matcher.js";
import { prisma } from "../src/db/prisma.js";
import type { IngredientMatchEntry, UnitMatchEntry } from "../src/lib/ingredient-matcher.js";
import type { ParsedRecipe, ParsedRecipeIngredient } from "../src/lib/recipe-source-adapter.js";
import {
mergeDuplicateIngredients,
type TranslatedRecipeIngredient,
translateRecipe,
translateRecipeIngredients,
translateRecipeSteps,
type UnitConversionEntry,
} from "../../src/lib/recipe-matching/recipe-translation.js";
import type {
ParsedRecipe,
ParsedRecipeIngredient,
} from "../../src/lib/recipe-sources/recipe-source-adapter.js";
import { resetDatabase } from "../../test-support/reset-db.js";
} from "../src/lib/recipe-translation.js";
import type { TechStepMappingRule } from "../src/lib/tech-step-matcher.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** A minimal fixture `ParsedRecipe` — only `steps` (built from `descriptions`) matters for most tests here, the rest is filler to prove it survives translation untouched. */
function buildParsedRecipe(descriptions: string[]): ParsedRecipe {
@ -35,67 +27,56 @@ function buildParsedRecipe(descriptions: string[]): ParsedRecipe {
}
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
// `tech-step-matcher.test.ts`'s own `techStepClassifier` describe block
// takes, for the same reason.
describe("translateRecipeSteps", () => {
let simmerId: number;
let preheatId: number;
let meltId: number;
const simmer: TechStepMappingRule = {
techStepId: 1,
expression: "\\bmijot(er|ez|e|ant|é)\\b",
weight: 15,
};
const preheat: TechStepMappingRule = {
techStepId: 2,
expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b",
weight: 20,
};
const melt: TechStepMappingRule = {
techStepId: 3,
expression: "\\bfaire fondre\\b|\\bfaites fondre\\b",
weight: 15,
};
beforeEach(async () => {
await resetDatabase();
simmerId = (await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } })).id;
preheatId = (await prisma.techStep.findFirstOrThrow({ where: { key: "preheat" } })).id;
meltId = (await prisma.techStep.findFirstOrThrow({ where: { key: "melt" } })).id;
});
after(async () => {
await prisma.$disconnect();
});
it("declares each step's technique sequence, preserving order", async () => {
it("declares each step's technique sequence, preserving order", () => {
const recipe = buildParsedRecipe([
"Préchauffer la poêle, puis faire fondre le beurre",
"Servir immédiatement",
"Faire mijoter à feu doux",
]);
const translated = await translateRecipeSteps(recipe, "fr");
const translated = translateRecipeSteps(recipe, [simmer, preheat, melt]);
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([
[preheatId, meltId],
[],
[simmerId],
]);
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[2, 3], [], [1]]);
});
it("leaves description/picture untouched on each step", async () => {
const recipe = buildParsedRecipe(["Faire mijoter à feu doux", "Servir immédiatement"]);
it("leaves description/picture untouched on each step", () => {
const recipe = buildParsedRecipe(["Faire mijoter à feu doux", "Servir"]);
const translated = await translateRecipeSteps(recipe, "fr");
const translated = translateRecipeSteps(recipe, [simmer]);
expect(translated.steps[0]).to.deep.equal({
description: "Faire mijoter à feu doux",
picture: "https://example.test/step1.jpg",
techStepIds: [simmerId],
techStepIds: [1],
});
expect(translated.steps[1]).to.deep.equal({
description: "Servir immédiatement",
description: "Servir",
picture: null,
techStepIds: [],
});
});
it("passes every other field through unchanged", async () => {
const recipe = buildParsedRecipe(["Servir immédiatement"]);
it("passes every other field through unchanged", () => {
const recipe = buildParsedRecipe(["Servir"]);
const translated = await translateRecipeSteps(recipe, "fr");
const translated = translateRecipeSteps(recipe, []);
expect(translated.name).to.equal(recipe.name);
expect(translated.description).to.equal(recipe.description);
@ -104,31 +85,28 @@ describe("recipe-translation", () => {
expect(translated.sourceUrl).to.equal(recipe.sourceUrl);
});
it("stubs every ingredient's ingredientId/unitId to null, leaving the rest of it untouched — actual ingredient matching is translateRecipeIngredients' job", async () => {
const recipe = buildParsedRecipe(["Servir immédiatement"]);
it("stubs every ingredient's ingredientId/unitId to null, leaving the rest of it untouched — actual ingredient matching is translateRecipeIngredients' job", () => {
const recipe = buildParsedRecipe(["Servir"]);
const translated = await translateRecipeSteps(recipe, "fr");
const translated = translateRecipeSteps(recipe, []);
expect(translated.ingredients).to.deep.equal([
{ ...recipe.ingredients[0], ingredientId: null, unitId: null },
]);
});
it("gives every step an empty sequence when nothing in it means a known technique", async () => {
const recipe = buildParsedRecipe([
"Servir immédiatement",
"Ranger les couverts dans le tiroir",
]);
it("gives every step an empty sequence when there are no mappings at all", () => {
const recipe = buildParsedRecipe(["Faire mijoter à feu doux", "Préchauffer le four"]);
const translated = await translateRecipeSteps(recipe, "fr");
const translated = translateRecipeSteps(recipe, []);
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[], []]);
});
it("handles a recipe with no steps without error", async () => {
it("handles a recipe with no steps without error", () => {
const recipe = buildParsedRecipe([]);
const translated = await translateRecipeSteps(recipe, "fr");
const translated = translateRecipeSteps(recipe, [simmer]);
expect(translated.steps).to.deep.equal([]);
});
@ -218,32 +196,6 @@ describe("recipe-translation", () => {
expect(translated[0].unitId).to.equal(gram.unitId);
});
it("falls back to the generic 'piece' unit when a quantity was found but no unit word was (issue #53)", () => {
const piece: UnitMatchEntry = { unitId: 12, synonyms: ["piece", "pieces", "pc", "pcs"] };
const translated = translateRecipeIngredients(
[buildIngredient({ rawText: "4 Egg Yolks", name: "Egg Yolks" })],
[],
[gram, cup, piece],
);
expect(translated[0].quantity).to.equal(4);
expect(translated[0].unitId).to.equal(piece.unitId);
});
it("does not fall back to 'piece' when there's no quantity at all to count", () => {
const piece: UnitMatchEntry = { unitId: 12, synonyms: ["piece", "pieces", "pc", "pcs"] };
const translated = translateRecipeIngredients(
[buildIngredient({ rawText: "salt to taste", name: "salt" })],
[],
[piece],
);
expect(translated[0].quantity).to.equal(null);
expect(translated[0].unitId).to.equal(null);
});
it("leaves quantity/unitId null when rawText has neither a leading number nor a recognizable unit", () => {
const translated = translateRecipeIngredients(
[buildIngredient({ rawText: "salt to taste", name: "salt" })],
@ -265,111 +217,6 @@ describe("recipe-translation", () => {
});
});
describe("mergeDuplicateIngredients", () => {
const gram: UnitConversionEntry = { id: 1, type: "MASS", toBaseFactor: 1 };
const kilogram: UnitConversionEntry = { id: 2, type: "MASS", toBaseFactor: 1000 };
const milliliter: UnitConversionEntry = { id: 3, type: "VOLUME", toBaseFactor: 1 };
const piece: UnitConversionEntry = { id: 4, type: "COUNT", toBaseFactor: 1 };
const slice: UnitConversionEntry = { id: 5, type: "COUNT", toBaseFactor: 1 };
function buildLine(
overrides: Partial<TranslatedRecipeIngredient> & { rawText: string },
): TranslatedRecipeIngredient {
return {
name: overrides.rawText,
quantity: null,
unit: null,
ingredientId: null,
unitId: null,
...overrides,
};
}
it("sums the quantity of two lines resolving to the same ingredient, same unit (issue #53 follow-up)", () => {
const merged = mergeDuplicateIngredients(
[
buildLine({ rawText: "100g Sugar", ingredientId: 1, quantity: 100, unitId: gram.id }),
buildLine({ rawText: "45g Sugar", ingredientId: 1, quantity: 45, unitId: gram.id }),
],
[gram, kilogram, milliliter, piece, slice],
);
expect(merged).to.have.length(1);
expect(merged[0].quantity).to.equal(145);
expect(merged[0].unitId).to.equal(gram.id);
expect(merged[0].rawText).to.equal("100g Sugar + 45g Sugar");
});
it("converts through toBaseFactor when the duplicate uses a different unit of the same type", () => {
const merged = mergeDuplicateIngredients(
[
buildLine({ rawText: "500g Flour", ingredientId: 1, quantity: 500, unitId: gram.id }),
buildLine({
rawText: "0.5kg Flour",
ingredientId: 1,
quantity: 0.5,
unitId: kilogram.id,
}),
],
[gram, kilogram, milliliter, piece, slice],
);
expect(merged).to.have.length(1);
expect(merged[0].quantity).to.equal(1000);
expect(merged[0].unitId).to.equal(gram.id);
});
it("keeps duplicate COUNT-unit lines separate rather than guessing — a slice isn't a piece", () => {
const merged = mergeDuplicateIngredients(
[
buildLine({ rawText: "2 piece Bread", ingredientId: 1, quantity: 2, unitId: piece.id }),
buildLine({ rawText: "3 slice Bread", ingredientId: 1, quantity: 3, unitId: slice.id }),
],
[gram, kilogram, milliliter, piece, slice],
);
expect(merged).to.have.length(2);
});
it("keeps duplicate lines with incompatible unit types separate (MASS vs VOLUME)", () => {
const merged = mergeDuplicateIngredients(
[
buildLine({ rawText: "200g Milk", ingredientId: 1, quantity: 200, unitId: gram.id }),
buildLine({
rawText: "200ml Milk",
ingredientId: 1,
quantity: 200,
unitId: milliliter.id,
}),
],
[gram, kilogram, milliliter, piece, slice],
);
expect(merged).to.have.length(2);
});
it("never merges two unresolved lines (ingredientId: null) together", () => {
const merged = mergeDuplicateIngredients(
[
buildLine({ rawText: "1 vanilla pod", quantity: 1 }),
buildLine({ rawText: "1 vanilla pod", quantity: 1 }),
],
[gram, kilogram, milliliter, piece, slice],
);
expect(merged).to.have.length(2);
});
it("leaves non-duplicate lines untouched and preserves order", () => {
const sugar = buildLine({ rawText: "Sugar", ingredientId: 1, quantity: 1, unitId: gram.id });
const salt = buildLine({ rawText: "Salt", ingredientId: 2, quantity: 1, unitId: gram.id });
const merged = mergeDuplicateIngredients([sugar, salt], [gram, kilogram, milliliter]);
expect(merged).to.deep.equal([sugar, salt]);
});
});
describe("translateRecipe", () => {
beforeEach(async () => {
await resetDatabase();
@ -454,47 +301,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 +309,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

@ -6,11 +6,8 @@ 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 type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
import {
clearRecipeSources,
registerRecipeSource,
} from "../src/lib/recipe-sources/recipe-source-registry.js";
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */
@ -63,7 +60,7 @@ async function techStepId(key: string): Promise<number> {
return techStep.id;
}
/** A step's detected technique sequence, in order — mirrors `matchTechSteps`' return shape (`../src/lib/recipe-matching/tech-step-matcher.js`) so tests can assert on it directly. */
/** A step's detected technique sequence, in order — mirrors `matchTechSteps`' return shape (`../src/lib/tech-step-matcher.js`) so tests can assert on it directly. */
async function stepTechStepIds(stepId: number): Promise<number[]> {
const links = await prisma.stepTechStep.findMany({
where: { stepId },
@ -571,32 +568,6 @@ describe("Recipes", () => {
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects the same ingredientId listed twice with 400 VALIDATION_ERROR, not a 500 (issue #53 follow-up)", async () => {
// `RecipeIngredient`'s primary key is `(recipeId, ingredientId)` — a
// manual creation can't reach this via the web UI (`IngredientPicker`
// hides an already-picked ingredient), but nothing stops a raw
// request (or a source import, whose lines aren't deduplicated) from
// sending it — must fail cleanly instead of crashing on the DB's
// unique-constraint violation.
const { agent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const res = await agent.post("/recipes").send({
name: "Test",
portions: 4,
dietIds: [],
ingredients: [
{ ingredientId: tomate, quantity: 1, unitId: piece },
{ ingredientId: tomate, quantity: 2, unitId: piece },
],
steps: [{ description: "Étape" }],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects a missing or non-positive portions with 400 VALIDATION_ERROR", async () => {
const { agent } = await signup();
const tomate = await ingredientId("tomato");
@ -776,8 +747,8 @@ describe("Recipes", () => {
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");
const chop = await techStepId("chop");
const _mince = await techStepId("mince");
const _melt = await techStepId("melt");
const mince = await techStepId("mince");
const melt = await techStepId("melt");
const simmer = await techStepId("simmer");
const bake = await techStepId("bake");
@ -889,7 +860,7 @@ describe("Recipes", () => {
});
it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
const { profileId } = await signup();
const { agent, profileId } = await signup();
const { agent: otherAgent } = await signup();
const tomate = await ingredientId("tomato");
const piece = await unitId("piece");

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,12 +3,9 @@ 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 type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
import {
clearRecipeSources,
registerRecipeSource,
} from "../src/lib/recipe-sources/recipe-source-registry.js";
import { seedReferenceData } from "../src/db/reference-seed-data.js";
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */
@ -137,9 +134,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"]);
});
@ -151,38 +146,15 @@ describe("Reference data", () => {
expect(keys).to.deep.equal([...keys].sort());
});
it("reseeding is idempotent — no duplicate techniques", async () => {
it("reseeding is idempotent — no duplicate techniques or mappings", async () => {
// resetDatabase already seeded once in beforeEach; seed a second time
// on top of that without truncating, the way a redeploy would.
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);
// 26 techniques × one "fr" + one "en" mapping each.
expect(await prisma.techStepMapping.count()).to.equal(52);
});
});

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

@ -3,8 +3,8 @@ import {
clearRecipeSources,
getRecipeSource,
listRecipeSources,
} from "../../src/lib/recipe-sources/recipe-source-registry.js";
import { registerAllRecipeSources } from "../../src/sources/index.js";
} from "../src/lib/recipe-source-registry.js";
import { registerAllRecipeSources } from "../src/sources/index.js";
// Not exercised by any other test file — `registerAllRecipeSources` is
// deliberately never imported by `app.ts` (see its own doc comment), so
@ -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

@ -3,21 +3,18 @@ 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 { syncRecipeSources } from "../../src/db/recipe-source-sync.js";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../../src/lib/recipe-sources/recipe-source-adapter.js";
import { RecipeSourceFetchError } from "../../src/lib/recipe-sources/recipe-source-errors.js";
import {
clearRecipeSources,
registerRecipeSource,
} from "../../src/lib/recipe-sources/recipe-source-registry.js";
import { resetDatabase } from "../../test-support/reset-db.js";
} from "../src/lib/recipe-source-adapter.js";
import { RecipeSourceFetchError } from "../src/lib/recipe-source-errors.js";
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `recipe.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
function buildSignupPayload(): SignupInput {
@ -81,94 +78,6 @@ function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<{ externalId:
};
}
/**
* A fake adapter whose two ingredient lines both resolve to the same real
* seeded ingredient ("onion"), in the same unit (grams) exercises
* `previewSourceItem`'s duplicate-merging (`mergeDuplicateIngredients`,
* see issue #53's follow-up) through the real HTTP endpoint/catalog,
* rather than only as a pure unit test of the merge function itself.
*/
function buildDuplicateIngredientAdapter(key = "duplicateFakeSource"): RecipeSourceAdapter<{
externalId: string;
}> {
return {
key,
name: "Fake Source With Duplicates",
official: true,
iconUrl: null,
locale: "en",
async list(): Promise<RecipeSourceListResult> {
return { items: [], nextCursor: null };
},
async fetchDetail(externalId: string): Promise<{ externalId: string }> {
return { externalId };
},
parse(raw: { externalId: string }): ParsedRecipe {
return {
name: `Fake recipe ${raw.externalId}`,
description: null,
picture: null,
portions: 4,
sourceUrl: `https://fake.test/${raw.externalId}`,
ingredients: [
{ rawText: "100g Onion", quantity: null, unit: null, name: "onion" },
{ rawText: "50g Onion", quantity: null, unit: null, name: "onion" },
],
steps: [{ description: "Chop the onions finely", picture: null }],
};
},
};
}
/**
* 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 } });
@ -353,59 +262,6 @@ describe("Sources", () => {
expect(res.status).to.equal(404);
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());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({
where: { key: "duplicateFakeSource" },
});
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
const onion = await prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } });
const gram = await prisma.unit.findFirstOrThrow({ where: { key: "gram" } });
const res = await agent.get("/sources/duplicateFakeSource/preview/1");
expect(res.status).to.equal(200);
expect(res.body.ingredients).to.have.length(1);
const [merged] = res.body.ingredients;
expect(merged.ingredient).to.deep.include({ id: onion.id, key: "onion" });
expect(merged.unit).to.deep.include({ id: gram.id, key: "gram" });
expect(merged.quantity).to.equal(150);
expect(merged.rawText).to.equal("100g Onion + 50g Onion");
});
});
describe("POST /sources/:sourceKey/import/:externalId", () => {
@ -501,23 +357,6 @@ describe("Sources", () => {
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
});
it("rejects the same ingredientId listed twice with 400 VALIDATION_ERROR, not a 500 (issue #53 follow-up)", async () => {
// A source's raw ingredient lines aren't deduplicated by the matcher
// (see `ingredient-matcher.ts`) — two different lines (e.g. "Egg
// Yolks" and "Eggs") can resolve to the same catalog ingredient, same
// as `recipe.test.ts`'s equivalent for a manual creation, just
// reached here through the review screen's pre-filled payload
// instead.
const { agent } = await enableFakeSource();
const payload = await buildImportPayload();
payload.ingredients.push({ ...payload.ingredients[0] });
const res = await agent.post("/sources/fakeSource/import/1").send(payload);
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects a second household's import of the same item too — the item's identity is global, not per-household", async () => {
// Registers/syncs the adapter once — enableFakeSource() itself does
// this too, and registerRecipeSource() throws on a duplicate key, so

View file

@ -0,0 +1,254 @@
import { expect } from "chai";
import { prisma } from "../src/db/prisma.js";
import {
type TechStepMappingRule,
loadTechStepMappingRules,
matchTechStepSpans,
matchTechSteps,
normalizeText,
} from "../src/lib/tech-step-matcher.js";
import { resetDatabase } from "../test-support/reset-db.js";
describe("tech-step-matcher", () => {
describe("normalizeText", () => {
it("lowercases and strips accents", () => {
expect(normalizeText("Déglacer AU FOUR")).to.equal("deglacer au four");
});
it("strips a variety of diacritics, including cedilla", () => {
expect(normalizeText("Façon Œuf à l'Étouffée")).to.equal("facon œuf a l'etouffee");
});
it("leaves already-plain text unchanged, aside from casing", () => {
expect(normalizeText("Mix everything")).to.equal("mix everything");
});
it("returns an empty string for an empty input", () => {
expect(normalizeText("")).to.equal("");
});
});
describe("matchTechSteps", () => {
const simmer: TechStepMappingRule = {
techStepId: 1,
expression: "\\bmijot(er|ez|e|ant|é)\\b",
weight: 15,
};
const cook: TechStepMappingRule = {
techStepId: 2,
expression: "\\bcui(re|sez|sant|sson)\\b|\\bcuit(e|es|s)?\\b",
weight: 10,
};
const bake: TechStepMappingRule = {
techStepId: 3,
expression:
"\\bcuire au four\\b|\\bcuisson au four\\b|\\benfourn(er|ez|é|ée|ées)\\b|\\bau four\\b",
weight: 25,
};
const preheat: TechStepMappingRule = {
techStepId: 4,
expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b",
weight: 20,
};
const melt: TechStepMappingRule = {
techStepId: 5,
expression: "\\bfondre\\b|\\bfaire fondre\\b|\\bfaites fondre\\b",
weight: 15,
};
it("matches an exact expression", () => {
expect(matchTechSteps("Faire mijoter à feu doux", [simmer])).to.deep.equal([1]);
});
it("is case- and accent-insensitive, on both the description and the expression itself", () => {
// `simmer`'s own expression source contains a literal "é" — exercises
// normalizeText being applied to the expression, not just the description.
expect(matchTechSteps("FAIRE MIJOTER", [simmer])).to.deep.equal([1]);
expect(matchTechSteps("faire mijote", [simmer])).to.deep.equal([1]);
});
it("returns an empty sequence when nothing matches", () => {
expect(matchTechSteps("Servir immédiatement", [simmer, cook, bake])).to.deep.equal([]);
});
it("returns an empty sequence for an empty mappings list", () => {
expect(matchTechSteps("Faire mijoter à feu doux", [])).to.deep.equal([]);
});
it("returns an empty sequence for an empty description", () => {
expect(matchTechSteps("", [simmer, cook, bake])).to.deep.equal([]);
});
it("detects several distinct, non-overlapping techniques as an ordered sequence", () => {
// The motivating case: "Dans une poêle chaude, faire chauffer une noix
// de beurre" involves both preheating and melting — a step can name
// more than one technique, in the order they're mentioned.
expect(
matchTechSteps("Préchauffer la poêle, puis faire fondre le beurre", [preheat, melt]),
).to.deep.equal([4, 5]);
// Order in the output follows order of mention in the text, not
// argument order.
expect(
matchTechSteps("Préchauffer la poêle, puis faire fondre le beurre", [melt, preheat]),
).to.deep.equal([4, 5]);
});
it("reverses the sequence when the techniques are mentioned in the opposite order", () => {
expect(
matchTechSteps("Faire fondre le beurre puis préchauffer le four", [preheat, melt]),
).to.deep.equal([5, 4]);
});
it("keeps only the highest-weight technique when two different techniques' expressions overlap the same words", () => {
// "Cuire au four" matches both `cook` (weight 10) and `bake` (weight
// 25) at essentially the same span — only the more specific `bake`
// should survive, not both.
expect(matchTechSteps("Cuire au four pendant 30 minutes", [cook, bake])).to.deep.equal([3]);
// Order-independent.
expect(matchTechSteps("Cuire au four pendant 30 minutes", [bake, cook])).to.deep.equal([3]);
});
it("still keeps a non-overlapping technique alongside an overlap-resolved one", () => {
// `bake` wins over `cook` for "cuire au four" (overlap), but `melt`
// matches an entirely different, non-overlapping span and survives.
const result = matchTechSteps("Faire fondre le beurre, puis cuire au four", [
cook,
bake,
melt,
]);
expect(result).to.deep.equal([5, 3]);
});
it("breaks a same-span weight tie by lowest techStepId", () => {
const a: TechStepMappingRule = { techStepId: 5, expression: "\\bmelanger\\b", weight: 10 };
const b: TechStepMappingRule = { techStepId: 2, expression: "\\bmelanger\\b", weight: 10 };
expect(matchTechSteps("Mélanger les ingrédients", [a, b])).to.deep.equal([2]);
});
it("still resolves to one techStep when two of its own mappings both match", () => {
const wholeWord: TechStepMappingRule = {
techStepId: 7,
expression: "\\bmijoter\\b",
weight: 15,
};
const withAdverb: TechStepMappingRule = {
techStepId: 7,
expression: "\\bmijoter à feu doux\\b",
weight: 15,
};
expect(matchTechSteps("Faire mijoter à feu doux", [wholeWord, withAdverb])).to.deep.equal([
7,
]);
});
it("respects word boundaries — a technique's verb embedded in a longer word doesn't false-positive", () => {
// "recuire"/"précuit" contain "cuire"/"cuit" as a substring, but not as
// a standalone word — the \b-anchored expression must not match them.
expect(matchTechSteps("Faire recuire la sauce", [cook])).to.deep.equal([]);
expect(matchTechSteps("Un plat précuit", [cook])).to.deep.equal([]);
// The standalone forms still match.
expect(matchTechSteps("Faire cuire la sauce", [cook])).to.deep.equal([2]);
expect(matchTechSteps("Le riz est cuit", [cook])).to.deep.equal([2]);
});
});
describe("matchTechStepSpans", () => {
// Same fixtures as `matchTechSteps` above (kept local to this describe
// block rather than shared — each block's fixtures should be readable
// on their own).
const simmer: TechStepMappingRule = {
techStepId: 1,
expression: "\\bmijot(er|ez|e|ant|é)\\b",
weight: 15,
};
const cook: TechStepMappingRule = {
techStepId: 2,
expression: "\\bcui(re|sez|sant|sson)\\b|\\bcuit(e|es|s)?\\b",
weight: 10,
};
const bake: TechStepMappingRule = {
techStepId: 3,
expression:
"\\bcuire au four\\b|\\bcuisson au four\\b|\\benfourn(er|ez|é|ée|ées)\\b|\\bau four\\b",
weight: 25,
};
const preheat: TechStepMappingRule = {
techStepId: 4,
expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b",
weight: 20,
};
const melt: TechStepMappingRule = {
techStepId: 5,
expression: "\\bfondre\\b|\\bfaire fondre\\b|\\bfaites fondre\\b",
weight: 15,
};
it("returns the matched span alongside the techStepId for a simple match", () => {
// "Faire mijoter à feu doux" — "mijoter" starts right after "Faire ".
expect(matchTechStepSpans("Faire mijoter à feu doux", [simmer])).to.deep.equal([
{ techStepId: 1, start: 6, end: 13 },
]);
});
it("returns an empty list when nothing matches", () => {
expect(matchTechStepSpans("Servir immédiatement", [simmer, cook, bake])).to.deep.equal([]);
});
it("returns each distinct technique's own span, in reading order", () => {
const text = "Préchauffer la poêle, puis faire fondre le beurre";
const result = matchTechStepSpans(text, [preheat, melt]);
expect(result).to.have.length(2);
expect(result[0].techStepId).to.equal(4);
expect(result[1].techStepId).to.equal(5);
// Each span, sliced back out of the original text, is exactly the
// word(s) that triggered that match — what the frontend needs to
// highlight the right characters.
expect(text.slice(result[0].start, result[0].end).toLowerCase()).to.equal("préchauffer");
expect(text.slice(result[1].start, result[1].end).toLowerCase()).to.equal("faire fondre");
});
it("keeps only the winning span when two techniques' expressions overlap", () => {
// `bake` (weight 25) wins over `cook` (weight 10) for "cuire au four"
// — only bake's span survives, not two overlapping entries.
const text = "Cuire au four pendant 30 minutes";
const result = matchTechStepSpans(text, [cook, bake]);
expect(result).to.deep.equal([{ techStepId: 3, start: 0, end: 13 }]);
expect(text.slice(0, 13)).to.equal("Cuire au four");
});
});
describe("loadTechStepMappingRules", () => {
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
it("only returns mappings for the requested locale", async () => {
const simmer = await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } });
// "de" has no seeded mappings at all (unlike "fr"/"en", which the
// real catalog now both populate) — a clean locale to attach one
// synthetic row to without conflating it with real seed data.
await prisma.techStepMapping.create({
data: { techStepId: simmer.id, locale: "de", expression: "\\bsimmer\\b", weight: 15 },
});
// The seeded catalog (26 "fr" mappings) must be untouched by the extra
// "de" row — same count, and none of them carry its expression.
const frRules = await loadTechStepMappingRules("fr");
expect(frRules).to.have.length(26);
expect(frRules.map((rule) => rule.expression)).to.not.include("\\bsimmer\\b");
const deRules = await loadTechStepMappingRules("de");
expect(deRules).to.deep.equal([
{ techStepId: simmer.id, expression: "\\bsimmer\\b", weight: 15 },
]);
});
it("returns an empty list for a locale with no mappings at all", async () => {
expect(await loadTechStepMappingRules("de")).to.deep.equal([]);
});
});
});

View file

@ -1,9 +1,6 @@
import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { type TheMealDbMeal, theMealDbAdapter } from "../../src/sources/the-meal-db.js";
import { RecipeSourceFetchError, RecipeSourceParseError } from "../src/lib/recipe-source-errors.js";
import { type TheMealDbMeal, theMealDbAdapter } from "../src/sources/the-meal-db.js";
/**
* Stubs `globalThis.fetch` for one test no HTTP-mocking library exists
@ -157,20 +154,6 @@ describe("theMealDbAdapter", () => {
]);
});
it("drops lone step-number lines instead of turning them into bogus steps (issue #52)", () => {
const parsed = theMealDbAdapter.parse({
...baseMeal,
strInstructions:
"For the caramel, melt the sugar.\r\n\r\n2\r\n\r\nPreheat the oven.\r\n\r\n3\r\n\r\nBake it.",
});
expect(parsed.steps).to.deep.equal([
{ description: "For the caramel, melt the sugar.", picture: null },
{ description: "Preheat the oven.", picture: null },
{ description: "Bake it.", picture: null },
]);
});
it("throws RecipeSourceParseError when the meal has no name", () => {
expect(() => theMealDbAdapter.parse({ ...baseMeal, strMeal: null })).to.throw(
RecipeSourceParseError,

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

@ -1,63 +1,47 @@
import type { StepTechStepView } from "@batch-cooking/shared";
import { splitDescriptionByTechSteps } from "../../src/features/recipes/steps/highlight-tech-steps";
import { splitDescriptionByTechSteps } from "../../src/features/recipes/highlight-tech-steps";
// Pure logic, no DOM/mount needed — reuses the component-test runner
// (Cypress's Mocha/Chai, same as CheckboxOption.cy.tsx) purely for its
// `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. */
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 } : {}),
};
function techStep(key: string, id: number, start: number, end: number): StepTechStepView {
return { techStep: { id, key }, start, end };
}
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 },
]);
});
it("splits a single keyword-only match (no context) into before/match/after segments", () => {
// "Faire mijoter à feu doux" — "mijoter" is [6, 13). Same shape as
// before context spans existed at all — the common case for a short,
// already-imperative clause where the keyword and its context coincide.
it("splits a single match into before/match/after segments", () => {
// "Faire mijoter à feu doux" — "mijoter" is [6, 13).
const result = splitDescriptionByTechSteps("Faire mijoter à feu doux", [
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 },
{ text: "mijoter", techStep: { id: 1, key: "simmer" } },
{ text: " à feu doux", techStep: null },
]);
});
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" } },
{ text: " les oignons", techStep: null },
]);
});
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 },
{ text: "cuire", techStep: { id: 3, key: "cook" } },
]);
});
@ -69,45 +53,34 @@ describe("splitDescriptionByTechSteps", () => {
]);
expect(result.map((s) => s.text).join("")).to.equal(text);
expect(result.filter((s) => s.techStep !== null)).to.have.length(2);
expect(result[0]).to.deep.equal({
text: "Préchauffer",
techStep: { id: 4, key: "preheat" },
isKeyword: true,
source: "auto",
});
expect(result[0]).to.deep.equal({ text: "Préchauffer", techStep: { id: 4, key: "preheat" } });
});
it("re-sorts entries that aren't already in start order", () => {
const text = "Faire fondre le beurre puis préchauffer le four";
// Passed in techStepId order, not text order — the function must sort
// by position, not trust the input order.
// by `start`, not trust the input order.
const result = splitDescriptionByTechSteps(text, [
techStep("preheat", 4, 28, 39),
techStep("melt", 5, 0, 12),
]);
const matches = result.filter((s) => s.techStep !== null && s.isKeyword);
const matches = result.filter((s) => s.techStep !== null);
expect(matches.map((s) => s.techStep?.key)).to.deep.equal(["melt", "preheat"]);
});
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 }]);
});
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 }]);
});
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 }]);
});
it("drops a later match that overlaps one already accepted", () => {
@ -118,133 +91,10 @@ describe("splitDescriptionByTechSteps", () => {
techStep("bake", 3, 0, 13),
techStep("cook", 2, 0, 5),
]);
expect(result).to.deep.equal([
{ text: "Cuire au four", techStep: { id: 3, key: "bake" }, isKeyword: true, source: "auto" },
]);
expect(result).to.deep.equal([{ text: "Cuire au four", techStep: { id: 3, key: "bake" } }]);
});
it("returns a single empty-ish segment for an empty description with no matches", () => {
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
// noix de beurre" — `preheat`'s keyword is "poêle chaude", its
// context is the whole "Dans une poêle chaude" clause around it.
const text = "Dans une poêle chaude, faire chauffer une noix de beurre";
const result = splitDescriptionByTechSteps(text, [
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: ", faire chauffer une noix de beurre",
techStep: null,
isKeyword: false,
source: null,
},
]);
});
it("omits the context-before segment when the keyword starts right at the context's own start", () => {
const result = splitDescriptionByTechSteps("préchauffer le four", [
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" },
]);
});
it("omits the context-after segment when the keyword ends right at the context's own end", () => {
const result = splitDescriptionByTechSteps("mettre le four à préchauffer", [
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",
},
]);
});
it("falls back to a keyword-only segment when context is absent (an older, not-yet-recomputed match)", () => {
const result = splitDescriptionByTechSteps("Faire mijoter à feu doux", [
techStep("simmer", 1, 6, 13),
]);
expect(result.some((s) => s.techStep !== null && !s.isKeyword)).to.equal(false);
});
it("drops an entry whose context doesn't actually contain its own keyword span", () => {
const result = splitDescriptionByTechSteps("Cuire au four", [
// 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 },
]);
});
});
});

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

@ -13,26 +13,17 @@ Feature: Adding a recipe to the planning
And the sources reference list has options
And the household has enabled TheMealDB
And browsing TheMealDB returns some items
And the planning request returns nothing
And the planning request reflects whatever's been added so far
Scenario: Selecting a source item only previews it, the footer's Confirmer is what acts on it
Given previewing TheMealDB item "9999" is available
When I visit "/"
And I click the add button for the first empty planning slot
And I click the button "TheMealDB"
And I click the source item "Fish Pie"
Then the recipe detail panel heading should be "Fish Pie"
And the URL should be the home page
Scenario: Confirming a not-yet-imported source item lands on the embedded review form since an ingredient needs resolving
Scenario: Adds a not-yet-imported source item to a planning slot, importing it on the way
Given previewing TheMealDB item "9999" is available
And importing the previewed item will succeed and return id 99
And adding the imported recipe to the planning will succeed
When I visit "/"
And I click the add button for the first empty planning slot
And I click the button "TheMealDB"
And I click the button "Sources"
And I click the source item "Fish Pie"
And I click the button "Confirmer"
And I click the link "Importer cette recette"
Then I should see "Cette recette sera automatiquement ajoutée à votre planning une fois importée."
When I choose an ingredient for the unresolved line "some mystery paste"
@ -41,19 +32,5 @@ Feature: Adding a recipe to the planning
And I fill in the last ingredient's quantity with "1" and unit "unité"
And I click the button "Importer"
Then the planning add request should have included recipe 99, weekDay "lundi", meal "petit-dejeuner", and portions 4
And the recipe picker dialog should be closed
And the URL should be the home page
And the recipe "Fish Pie" should appear in the first planning slot with 4 portions
Scenario: Confirming a fully-resolved not-yet-imported item adds it to the planning transparently, with no review screen at all
Given previewing TheMealDB item "7777" is fully resolved as "Ratatouille"
And importing item "7777" will succeed and return id 100
And adding recipe 100 to the planning will succeed
When I visit "/"
And I click the add button for the first empty planning slot
And I click the button "TheMealDB"
And I click the source item "Ratatouille"
And I click the button "Confirmer"
Then the import request for "7777" should have included the name "Ratatouille"
And the planning add request should have included recipe 100, weekDay "lundi", meal "petit-dejeuner", and portions 4
And the recipe picker dialog should be closed
And the recipe "Ratatouille" should appear in the first planning slot with 4 portions

View file

@ -13,6 +13,13 @@ import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
// its own comment for the full reasoning), so most of what's below mirrors
// recipe-sources.ts's fixtures rather than importing them.
// Flips once, from `false` to `true`, as the single scenario in this file
// actually performs the planning-add — module-level `let` rather than
// something reset per-scenario, since there's only ever the one here (see
// household-settings.ts for the same pattern used across several scenarios
// instead).
let fishPiePlanned = false;
Given("the recipe catalog contains nothing", () => {
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
});
@ -42,18 +49,6 @@ Given("browsing TheMealDB returns some items", () => {
alreadyImported: false,
recipeId: null,
},
// Draft's own preview ("previewing TheMealDB item ... is fully
// resolved") has nothing left for a person to fix — unlike "Fish
// Pie" above, exercises the transparent-import path instead of the
// review screen.
{
externalId: "7777",
title: "Ratatouille",
picture: null,
url: "https://www.themealdb.com/meal/7777",
alreadyImported: false,
recipeId: null,
},
],
nextCursor: null,
},
@ -94,76 +89,6 @@ Given("previewing TheMealDB item {string} is available", (externalId: string) =>
});
});
// Unlike "previewing TheMealDB item ... is available" above, every
// ingredient line here already resolved to a real ingredient/unit/quantity
// — `tryBuildCompleteImport` (RecipePickerDialog.tsx) accepts a draft
// shaped exactly like this one as-is, no review screen needed.
Given(
"previewing TheMealDB item {string} is fully resolved as {string}",
(externalId: string, name: string) => {
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
statusCode: 200,
body: {
sourceKey: "theMealDb",
externalId,
name,
description: null,
picture: null,
portions: 4,
sourceUrl: `https://www.themealdb.com/meal/${externalId}`,
ingredients: [
{
rawText: "1 onion",
quantity: 1,
ingredient: {
id: 1,
key: "onion",
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
reproducible: false,
allergens: [],
diets: [],
},
unit: { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 },
},
],
steps: [{ description: "Cuire à la poêle.", picture: null, techSteps: [] }],
},
});
},
);
Given(
"importing item {string} will succeed and return id {int}",
(externalId: string, id: number) => {
cy.intercept("POST", `**/sources/theMealDb/import/${externalId}`, {
statusCode: 201,
body: { id },
}).as("importItem");
},
);
Given("adding recipe {int} to the planning will succeed", (recipeId: number) => {
cy.intercept("POST", "**/planning/items", {
statusCode: 201,
body: {
id: 2,
weekDay: "lundi",
meal: "petit-dejeuner",
portions: 4,
recipe: { id: recipeId, name: "Ratatouille" },
},
}).as("addPlanningItem");
});
Then(
"the import request for {string} should have included the name {string}",
(_externalId: string, name: string) => {
cy.wait("@importItem").its("request.body.name").should("eq", name);
},
);
// Covers every reference catalog both `RecipePickerDialog` (ingredients/
// diets, for its own filters) and `ImportRecipePage` (ingredients/diets/
// units, for the review form) fetch — same endpoints, one fixture for both.
@ -209,7 +134,9 @@ Given("importing the previewed item will succeed and return id {int}", (id: numb
});
Given("adding the imported recipe to the planning will succeed", () => {
cy.intercept("POST", "**/planning/items", {
cy.intercept("POST", "**/planning/items", (req) => {
fishPiePlanned = true;
req.reply({
statusCode: 201,
body: {
id: 1,
@ -218,17 +145,39 @@ Given("adding the imported recipe to the planning will succeed", () => {
portions: 4,
recipe: { id: 99, name: "Fish Pie" },
},
});
}).as("addPlanningItem");
});
// Every scenario here confirms/imports without ever leaving "/" (the
// footer's "Confirmer" patches the grid locally via `PlanningPage`'s own
// `onAdded` — `RecipePickerDialog`'s doc comment — rather than navigating
// away and back), so unlike a real cross-route remount, this fixture never
// needs to reflect what's been added: the grid picks it up from local
// state, not a fresh fetch.
Then("the recipe picker dialog should be closed", () => {
cy.get(".dialog-panel").should("not.exist");
// Stateful — landing back on "/" after the import journey remounts
// `PlanningPage` from scratch (a real cross-route navigation, not a
// same-component state update: see `ImportRecipePage`'s `navigate("/")`),
// so only a fresh `GET /planning?date=` that reflects the just-added item
// makes it show up there — nothing client-side survives that remount to
// patch it in locally the way `PlanningPage`'s own `patchPlanningItems`
// does for an add made without leaving the page.
Given("the planning request reflects whatever's been added so far", () => {
cy.intercept("GET", /\/planning\?/, (req) => {
req.reply({
statusCode: 200,
body: fishPiePlanned
? {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: [
{
id: 1,
weekDay: "lundi",
meal: "petit-dejeuner",
portions: 4,
recipe: { id: 99, name: "Fish Pie" },
},
],
}
: null,
});
});
});
// The very first "+" in DOM order is Lundi's Petit-déjeuner cell (`MEALS`'s

View file

@ -37,10 +37,10 @@ Feature: Recipe form — associating ingredients
When I visit the new recipe form without a secure random UUID
And I fill in the "recipe-name" field with "Recette hors contexte sécurisé"
And I select the ingredient "Tomate" from the picker
And I select the ingredient "Oeuf" from the picker
And I select the ingredient "Œuf" from the picker
Then there should be 2 ingredient rows
And the recipe should include the ingredient "Tomate"
And the recipe should include the ingredient "Oeuf"
And the recipe should include the ingredient "Œuf"
When I add a step
And I add a step
Then there should be 2 step editor items
@ -57,7 +57,7 @@ Feature: Recipe form — associating ingredients
Given recipe 7 exists with an egg omelette
And updating recipe 7 will succeed
When I visit "/recettes/7/modifier"
Then the recipe should include the ingredient "Oeuf"
Then the recipe should include the ingredient "Œuf"
And the ingredient's quantity should be "3"
When I select the ingredient "Tomate" from the picker
Then there should be 2 ingredient rows

View file

@ -9,12 +9,13 @@ Feature: Browsing external recipe sources
And the disliked ingredients list is empty
And the planning request returns nothing
Scenario: Shows no source tab when the household hasn't enabled any
Scenario: Prompts to enable a source when the household hasn't enabled any
Given the recipe catalog contains nothing
And the sources reference list has options
And the household's enabled sources are empty
When I visit "/recettes"
Then I should not see "TheMealDB"
And I click the button "Sources"
Then I should see "Aucune source n'est activée"
Scenario: Browses an enabled source, distinguishing already-imported items from new ones
Given the recipe catalog contains nothing
@ -23,7 +24,7 @@ Feature: Browsing external recipe sources
And browsing TheMealDB returns some items
And recipe 2's detail is available
When I visit "/recettes"
And I click the button "TheMealDB"
And I click the button "Sources"
Then I should see the source item "Chicken Handi"
And I should see the source item "Fish Pie"
And the source item "Chicken Handi" should be marked as already imported
@ -39,42 +40,34 @@ Feature: Browsing external recipe sources
And browsing TheMealDB returns some items
And previewing TheMealDB item "9999" is available
When I visit "/recettes"
And I click the button "TheMealDB"
And I click the button "Sources"
And I click the source item "Fish Pie"
Then the URL should include "/recettes/sources/theMealDb/9999"
And the recipe detail panel heading should be "Fish Pie"
Then 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
Scenario: Reviews an import, resolving an unrecognized ingredient before confirming
Given the recipe catalog contains nothing
And the sources reference list has options
And the household has enabled TheMealDB
And browsing TheMealDB returns some items
And previewing TheMealDB item "9999" is available
When I visit "/recettes/sources/theMealDb/9999"
Then the recipe detail panel heading should be "Fish Pie"
And I should not see "Importer cette recette"
And I should see a discreet link to the item's original page
And the ingredient and diet catalog is available for import
And importing the previewed item will succeed and return id 99
When I visit "/recettes"
And I click the button "Sources"
And I click the source item "Fish Pie"
And I click the link "Importer cette recette"
Then the "recipe-name" field should have the value "Fish Pie"
And the recipe should include the ingredient "Oignon"
When I choose an ingredient for the unresolved line "some mystery paste"
And I select the ingredient "Sel" from the picker
Then the unresolved ingredients section should no longer be shown
And there should be 2 ingredient rows
When I select unit "unité" for the first ingredient
And I fill in the last ingredient's quantity with "1" and unit "unité"
Then the "Importer" button should not be disabled
When I click the button "Importer"
Then the import request should have included ingredient 2 with quantity 1 and unitId 1
And the URL should include "/recettes/99"

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,
@ -210,9 +135,66 @@ Then("the source item {string} should be marked as already imported", (title: st
cy.contains("tr", title).find(".source-item-table__imported-badge").should("be.visible");
});
// Browsing a source (outside of adding-to-planning, `RecipePickerDialog`'s
// own scenarios in planning.ts) never imports anything — the only action
// this preview offers is a discreet way out to the item's own page.
Then("I should see a discreet link to the item's original page", () => {
cy.get(".recipe-detail-panel__source-link").should("be.visible");
// ImportRecipePage (the review screen) loads its own ingredient/diet/unit
// catalogs the same way RecipeFormPage does — "onion" matches the resolved
// line in "previewing TheMealDB item ... is available" above, "salt" is
// what "some mystery paste" (unresolved in that same fixture) gets
// corrected to in the review-and-import scenario.
Given("the ingredient and diet catalog is available for import", () => {
cy.intercept("GET", "**/reference/ingredients", {
statusCode: 200,
body: [
{
id: 1,
key: "onion",
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
allergens: [],
diets: [],
},
{
id: 2,
key: "salt",
icon: "SPICE",
category: "condimentsAndSpices",
subcategory: "spices",
allergens: [],
diets: [],
},
],
});
cy.intercept("GET", "**/reference/diets", {
statusCode: 200,
body: [{ id: 1, key: "omnivore" }],
});
cy.intercept("GET", "**/reference/units", {
statusCode: 200,
body: [{ id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 }],
});
});
Given("importing the previewed item will succeed and return id {int}", (id: number) => {
cy.intercept("POST", "**/sources/theMealDb/import/9999", { statusCode: 201, body: { id } }).as(
"importRecipe",
);
});
When("I choose an ingredient for the unresolved line {string}", (rawText: string) => {
cy.contains(".import-recipe__unresolved-row", rawText)
.contains("button", "Choisir un ingrédient")
.click();
});
Then("the unresolved ingredients section should no longer be shown", () => {
cy.get(".import-recipe__unresolved").should("not.exist");
});
Then(
"the import request should have included ingredient {int} with quantity {int} and unitId {int}",
(ingredientId: number, quantity: number, unitId: number) => {
cy.wait("@importRecipe")
.its("request.body.ingredients")
.should("include.deep.members", [{ ingredientId, quantity, unitId }]);
},
);

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