Compare commits
44 commits
fix/isolat
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 18d790b1bf | |||
| 3c8d169d6d | |||
| 7984499b48 | |||
| 0f0c5d2f65 | |||
| d7cb885ddb | |||
| 65b810c85b | |||
| cc4278ec68 | |||
| 9ec9b82e06 | |||
| f7aa696f55 | |||
| f19365e20e | |||
| 8a51f0bd6c | |||
| 32c7ed48c6 | |||
| 0ab30a4588 | |||
| 3c04efd16f | |||
| 7c423cc0fd | |||
| d58491e6f9 | |||
| 478914787f | |||
| 5ab9832131 | |||
|
|
eef5db92b5 | ||
|
|
550627919d | ||
|
|
bf58834aa9 | ||
|
|
109dde9c7b | ||
|
|
520e539fe6 | ||
|
|
ba3c978c25 | ||
|
|
88666f0ac5 | ||
|
|
5ea1026151 | ||
|
|
92bea914e8 | ||
|
|
0e0fd81563 | ||
|
|
66e5666687 | ||
|
|
52e379fcf7 | ||
|
|
5d63ff9ea9 | ||
|
|
f7d7664397 | ||
| 85fd9bae7d | |||
| 260bc3dc05 | |||
| b0c1d46acc | |||
| 933882a527 | |||
| fc8f38afde | |||
| f24b289cd4 | |||
| 73ae8169a1 | |||
|
|
0f5abb1749 | ||
| 4381e63045 | |||
|
|
174e16bf13 | ||
| d5172c2c66 | |||
| 368ea08960 |
244 changed files with 27656 additions and 4401 deletions
20
.env.example
20
.env.example
|
|
@ -21,3 +21,23 @@ 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
|
||||
|
|
|
|||
90
.github/workflows/ci.yml
vendored
90
.github/workflows/ci.yml
vendored
|
|
@ -12,22 +12,32 @@ on:
|
|||
push:
|
||||
|
||||
env:
|
||||
DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public"
|
||||
DATABASE_URL: "postgresql://ci:ci@postgres: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:
|
||||
# Four independent jobs, no needs: between them — each starts in parallel
|
||||
# Five 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: actions/checkout@v4
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
|
@ -45,34 +55,80 @@ jobs:
|
|||
POSTGRES_PASSWORD: ci
|
||||
POSTGRES_DB: batchcooking_ci
|
||||
ports:
|
||||
- 5432:5432
|
||||
- 5433:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: https://github.com/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: actions/checkout@v4
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
|
@ -83,17 +139,22 @@ jobs:
|
|||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Cache Cypress binary
|
||||
uses: actions/cache@v4
|
||||
uses: https://github.com/actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/Cypress
|
||||
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
|
@ -103,7 +164,10 @@ 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
|
||||
- run: pnpm --filter web e2e
|
||||
- name: Run E2E tests
|
||||
env:
|
||||
HOST: "0.0.0.0"
|
||||
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
12
.gitignore
vendored
|
|
@ -71,6 +71,13 @@ 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
|
||||
|
|
@ -151,3 +158,8 @@ 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/
|
||||
|
|
|
|||
3
CLAUDE.md
Normal file
3
CLAUDE.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# 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
418
README.md
|
|
@ -4,13 +4,21 @@
|
|||
|
||||
Monorepo pnpm workspaces :
|
||||
|
||||
- `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.
|
||||
- `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.
|
||||
- `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 :
|
||||
|
|
@ -19,11 +27,16 @@ 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` 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
|
||||
`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
|
||||
[specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts).
|
||||
|
||||
## Prérequis
|
||||
|
|
@ -31,6 +44,8 @@ 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
|
||||
|
||||
|
|
@ -49,6 +64,9 @@ 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
|
||||
|
|
@ -80,6 +98,21 @@ 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
|
||||
|
||||
|
|
@ -104,15 +137,46 @@ 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, démarre le serveur dev automatiquement)
|
||||
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 build # build de tous les workspaces
|
||||
```
|
||||
|
||||
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`).
|
||||
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.
|
||||
|
||||
## Déploiement
|
||||
|
||||
|
|
@ -126,10 +190,22 @@ 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.
|
||||
|
||||
`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).
|
||||
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.
|
||||
|
||||
**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
|
||||
|
|
@ -166,10 +242,14 @@ 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. 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é).
|
||||
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.
|
||||
|
||||
> **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 —
|
||||
|
|
@ -184,171 +264,178 @@ 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.
|
||||
|
||||
> **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.
|
||||
## 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).
|
||||
|
||||
## Planning (apps/api)
|
||||
|
||||
- `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`.
|
||||
- `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.
|
||||
|
||||
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).
|
||||
Le planning d'une semaine est créé à la demande (première recette ajoutée),
|
||||
jamais en avance.
|
||||
|
||||
## Données de référence — régimes & allergènes (apps/api)
|
||||
## Recettes — catalogue, favoris, import depuis une source externe (apps/api)
|
||||
|
||||
- `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).
|
||||
- `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).
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
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.
|
||||
## Données de référence — régimes, allergènes, ingrédients, unités, techniques (apps/api)
|
||||
|
||||
`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.
|
||||
- `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.
|
||||
|
||||
Liste des 14 allergènes : ceux du règlement UE 1169/2011 (annexe II) — liste
|
||||
standard, pas inventée.
|
||||
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).
|
||||
|
||||
**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).
|
||||
## Foyer & profil — régime, allergènes, ingrédients détestés (apps/api)
|
||||
|
||||
## Foyer & profil — nom, régime, allergènes (apps/api)
|
||||
Nécessitent tous une session (`requireAuth`) — données propres à
|
||||
l'utilisateur/au foyer, pas des données de référence.
|
||||
|
||||
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 (é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.
|
||||
`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`).
|
||||
|
||||
`apps/api/src/lib/safe-profile.ts` centralise le retrait du `passwordHash`
|
||||
(`toSafeProfile`), auparavant dupliqué dans `auth.service.ts` et
|
||||
`require-auth.ts` — `profile.service.ts` le réutilise aussi.
|
||||
(`toSafeProfile`).
|
||||
|
||||
## 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.
|
||||
- `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
|
||||
- `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
|
||||
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).
|
||||
|
||||
## Accueil, sidebar & sections (apps/web)
|
||||
## Sidebar, planning, recettes & sections (apps/web)
|
||||
|
||||
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é).
|
||||
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`).
|
||||
|
||||
## Parcours profil — foyer, régime, allergènes (apps/web)
|
||||
- **`/` — `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).
|
||||
|
||||
- `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".
|
||||
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).
|
||||
|
||||
**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.
|
||||
## Parcours d'inscription — onboarding (apps/web)
|
||||
|
||||
**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.
|
||||
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).
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
> **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** :
|
||||
> Chromium/Electron headless plante au lancement du process GPU
|
||||
|
|
@ -363,8 +450,9 @@ couvert par la suite Mocha d'`apps/api` (contre une vraie base).
|
|||
## 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, `404x` not
|
||||
found, `500x` interne — et `ApiErrorResponse`) : l'API renvoie toujours
|
||||
**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
|
||||
`{ 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` →
|
||||
|
|
@ -374,8 +462,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, exemples, comment ajouter un nouveau code d'erreur) :
|
||||
[specs/error-handling.md](specs/error-handling.md).
|
||||
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).
|
||||
|
||||
Le profil authentifié (`requireAuth`) passe par `res.locals.userProfile`
|
||||
(typé via `AuthLocals`), pas par une augmentation du namespace global Express —
|
||||
|
|
@ -391,9 +479,21 @@ 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. Détail :
|
||||
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 :
|
||||
[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
|
||||
|
|
|
|||
|
|
@ -12,3 +12,16 @@ 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
|
||||
|
|
|
|||
|
|
@ -13,3 +13,17 @@ 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
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@
|
|||
"extension": ["ts"],
|
||||
"spec": "test/**/*.test.ts",
|
||||
"node-option": ["import=tsx"],
|
||||
"timeout": 10000
|
||||
"timeout": 10000,
|
||||
"require": ["test-support/mocha-root-hooks.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
-- DropForeignKey
|
||||
ALTER TABLE "tech_step_mapping" DROP CONSTRAINT "tech_step_mapping_tech_step_id_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "tech_step_mapping";
|
||||
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "step_tech_step" ADD COLUMN "context_end" INTEGER,
|
||||
ADD COLUMN "context_start" INTEGER;
|
||||
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
-- 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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "step_tech_step" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'auto';
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
-- 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");
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
-- 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;
|
||||
|
|
@ -121,6 +121,10 @@ 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")
|
||||
}
|
||||
|
|
@ -517,6 +521,9 @@ 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")
|
||||
}
|
||||
|
|
@ -593,6 +600,12 @@ 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")
|
||||
}
|
||||
|
|
@ -619,34 +632,52 @@ 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[]
|
||||
mappings TechStepMapping[]
|
||||
/// 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[]
|
||||
|
||||
@@map("tech_step")
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// `key` is `@unique`, same bare id+key shape as `TechStep` — no
|
||||
/// categorization taxonomy like `Ingredient` needed yet, and no matching
|
||||
/// data of its own here either: unlike `TechStep` (whose matching synonyms
|
||||
/// used to live in TS and were moved into
|
||||
/// `services/tech-step-intent-service`'s `training_data.py`), this catalog
|
||||
/// was *born* owned by that service (`utensil_vocabulary.py`) since nothing
|
||||
/// pre-existing needed it — this row only exists to be a stable id/key
|
||||
/// `StepTechStepUtensil` references, and to carry a French label
|
||||
/// (`apps/web`'s `catalog.utensils.<key>`, see `reference-seed-data.ts`'s
|
||||
/// `UTENSILS`).
|
||||
model Utensil {
|
||||
id Int @id @default(autoincrement())
|
||||
techStepId Int @map("tech_step_id")
|
||||
locale String
|
||||
expression String
|
||||
weight Int
|
||||
key String @unique
|
||||
|
||||
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
|
||||
steps StepTechStepUtensil[]
|
||||
|
||||
@@map("tech_step_mapping")
|
||||
@@map("utensil")
|
||||
}
|
||||
|
||||
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the
|
||||
|
|
@ -662,6 +693,9 @@ 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")
|
||||
}
|
||||
|
|
@ -676,26 +710,190 @@ model Step {
|
|||
/// techniques in the description), not a global ordering across different
|
||||
/// steps of the recipe (that's `Step.order`).
|
||||
///
|
||||
/// `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
|
||||
/// `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
|
||||
/// 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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import { errorHandlerService } from "@batch-cooking/error-tools";
|
||||
import { ExpressServer, createErrorMiddleware } from "@batch-cooking/express-tools";
|
||||
import { createErrorMiddleware, ExpressServer } 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";
|
||||
|
||||
/**
|
||||
|
|
@ -22,6 +26,12 @@ 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) => {
|
||||
|
|
@ -30,11 +40,18 @@ 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
|
||||
|
|
@ -52,10 +69,12 @@ export function createServer(): ExpressServer {
|
|||
res.status(404).json({ code: ErrorCode.NOT_FOUND, message: "Not found" });
|
||||
});
|
||||
|
||||
// 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.
|
||||
// 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);
|
||||
server.setErrorHandler(createErrorMiddleware(errorHandlerService));
|
||||
|
||||
return server;
|
||||
|
|
|
|||
|
|
@ -60,6 +60,36 @@ 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. */
|
||||
|
|
|
|||
|
|
@ -1,3 +1,22 @@
|
|||
// 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";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { PrismaClient } from "@prisma/client";
|
||||
import { listRecipeSources } from "../lib/recipe-source-registry.js";
|
||||
import { listRecipeSources } from "../lib/recipe-sources/recipe-source-registry.js";
|
||||
|
||||
/**
|
||||
* Upserts one `Source` row (schema.prisma) per adapter currently in
|
||||
|
|
@ -25,10 +25,15 @@ import { listRecipeSources } from "../lib/recipe-source-registry.js";
|
|||
* 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,
|
||||
|
|
@ -37,6 +42,12 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -56,9 +67,12 @@ 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({
|
||||
|
|
@ -70,4 +84,7 @@ export async function findImportedRecipeIds(
|
|||
recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [],
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
throw err; // see syncRecipeSources()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,245 +51,144 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }>
|
|||
{ uid: "pound", type: "MASS", toBaseFactor: 453.5924 },
|
||||
];
|
||||
|
||||
// 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 },
|
||||
],
|
||||
},
|
||||
// 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",
|
||||
];
|
||||
|
||||
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
|
||||
|
|
@ -551,6 +450,13 @@ 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: [] },
|
||||
|
|
@ -601,7 +507,9 @@ 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.
|
||||
|
|
@ -937,7 +845,11 @@ export const INGREDIENT_GROUPS: Array<{
|
|||
subcategory: "eggs",
|
||||
defaultDiets: ["vegetarian", "pescatarian"],
|
||||
defaultIcon: "EGG",
|
||||
items: [{ uid: "egg", allergenUids: ["eggs"] }],
|
||||
items: [
|
||||
{ uid: "egg", allergenUids: ["eggs"] },
|
||||
{ uid: "eggYolk", allergenUids: ["eggs"] },
|
||||
{ uid: "eggWhite", allergenUids: ["eggs"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "dairyAndCheese",
|
||||
|
|
@ -997,6 +909,7 @@ 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: [] },
|
||||
|
|
@ -1375,36 +1288,16 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
|||
}
|
||||
|
||||
// TechStep: upsert by key (same idempotent-seed reasoning as everything
|
||||
// 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) {
|
||||
// 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) {
|
||||
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]));
|
||||
|
||||
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 });
|
||||
// Utensil: same idempotent bare id/key upsert as TechStep right above —
|
||||
// no matching data alongside it either (see `UTENSILS`' own comment).
|
||||
for (const key of UTENSILS) {
|
||||
await prisma.utensil.upsert({ where: { key }, update: {}, create: { key } });
|
||||
}
|
||||
|
||||
// `Allergy` itself carries no `key` — it's the selectable instance of a
|
||||
|
|
|
|||
|
|
@ -1,191 +0,0 @@
|
|||
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;
|
||||
}
|
||||
102
apps/api/src/lib/logger.service.ts
Normal file
102
apps/api/src/lib/logger.service.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
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();
|
||||
466
apps/api/src/lib/recipe-matching/ingredient-matcher.ts
Normal file
466
apps/api/src/lib/recipe-matching/ingredient-matcher.ts
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
102
apps/api/src/lib/recipe-matching/intent-service-client.ts
Normal file
102
apps/api/src/lib/recipe-matching/intent-service-client.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
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();
|
||||
331
apps/api/src/lib/recipe-matching/recipe-translation.ts
Normal file
331
apps/api/src/lib/recipe-matching/recipe-translation.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
263
apps/api/src/lib/recipe-matching/tech-step-eval-dataset.ts
Normal file
263
apps/api/src/lib/recipe-matching/tech-step-eval-dataset.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
/**
|
||||
* 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: [],
|
||||
},
|
||||
];
|
||||
71
apps/api/src/lib/recipe-matching/tech-step-eval-runner.ts
Normal file
71
apps/api/src/lib/recipe-matching/tech-step-eval-runner.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
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);
|
||||
}
|
||||
135
apps/api/src/lib/recipe-matching/tech-step-evaluator.ts
Normal file
135
apps/api/src/lib/recipe-matching/tech-step-evaluator.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* 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 };
|
||||
}
|
||||
618
apps/api/src/lib/recipe-matching/tech-step-matcher.ts
Normal file
618
apps/api/src/lib/recipe-matching/tech-step-matcher.ts
Normal file
|
|
@ -0,0 +1,618 @@
|
|||
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();
|
||||
|
|
@ -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 `matchTechStep` and its
|
||||
* DB-touching `loadTechStepMappingRules`. Whichever future layer
|
||||
* queries "which externalIds from this source do we already have"
|
||||
* `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"
|
||||
* (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 `TechStepMapping`/ingredient-label locale
|
||||
* Determines which trained-classifier/ingredient-label locale
|
||||
* `translateRecipe` (`recipe-translation.ts`) resolves this source's
|
||||
* recipes against when previewing/importing one.
|
||||
*/
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
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),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
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 },
|
||||
});
|
||||
}
|
||||
40
apps/api/src/middlewares/error-logger.ts
Normal file
40
apps/api/src/middlewares/error-logger.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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);
|
||||
}
|
||||
45
apps/api/src/middlewares/request-logger.ts
Normal file
45
apps/api/src/middlewares/request-logger.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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();
|
||||
}
|
||||
50
apps/api/src/middlewares/require-internal-worker.ts
Normal file
50
apps/api/src/middlewares/require-internal-worker.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
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();
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import type { CookieOptions, Response } from "express";
|
||||
import { Router } 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";
|
||||
|
|
|
|||
|
|
@ -35,7 +35,10 @@ 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> {
|
||||
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
||||
try {
|
||||
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");
|
||||
}
|
||||
|
|
@ -55,8 +58,18 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -74,7 +87,10 @@ 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> {
|
||||
const profile = await prisma.userProfile.findUnique({ where: { id: profileId } });
|
||||
try {
|
||||
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");
|
||||
}
|
||||
|
|
@ -83,6 +99,9 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -93,12 +112,21 @@ 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> {
|
||||
const profile = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
||||
try {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import {
|
||||
ErrorCode,
|
||||
createHouseSchema,
|
||||
ErrorCode,
|
||||
joinHouseSchema,
|
||||
renameHouseSchema,
|
||||
updateHouseSourcesSchema,
|
||||
|
|
|
|||
|
|
@ -44,10 +44,18 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -58,6 +66,7 @@ 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");
|
||||
}
|
||||
|
|
@ -68,6 +77,9 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -81,8 +93,13 @@ 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
|
||||
|
|
@ -93,18 +110,28 @@ export async function createHouse(
|
|||
try {
|
||||
const house = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.house.create({
|
||||
data: { name, adminId: profileId, inviteCode: generateInviteCode() },
|
||||
data: {
|
||||
name,
|
||||
adminId: profileId,
|
||||
inviteCode: generateInviteCode(),
|
||||
},
|
||||
});
|
||||
await tx.userProfile.update({
|
||||
where: { id: profileId },
|
||||
data: { houseId: created.id },
|
||||
});
|
||||
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: created.id } });
|
||||
return created;
|
||||
});
|
||||
return getCurrentHouseOrThrow(house.id);
|
||||
return await 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -118,8 +145,13 @@ 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 } });
|
||||
|
|
@ -131,8 +163,14 @@ export async function joinHouse(
|
|||
);
|
||||
}
|
||||
|
||||
await prisma.userProfile.update({ where: { id: profileId }, data: { houseId: house.id } });
|
||||
return getCurrentHouseOrThrow(house.id);
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -149,6 +187,7 @@ 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");
|
||||
}
|
||||
|
|
@ -157,7 +196,10 @@ 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;
|
||||
|
|
@ -169,8 +211,14 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -183,21 +231,32 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -215,6 +274,7 @@ 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");
|
||||
}
|
||||
|
|
@ -237,8 +297,14 @@ 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 getCurrentHouseOrThrow(house.id);
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -249,6 +315,7 @@ 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");
|
||||
}
|
||||
|
|
@ -257,6 +324,9 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -273,6 +343,7 @@ 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");
|
||||
}
|
||||
|
|
@ -294,15 +365,24 @@ 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}. */
|
||||
|
|
@ -324,6 +404,7 @@ 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,
|
||||
|
|
@ -332,4 +413,7 @@ 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
apps/api/src/modules/internal/tech-step-worker.routes.ts
Normal file
49
apps/api/src/modules/internal/tech-step-worker.routes.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
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));
|
||||
}),
|
||||
);
|
||||
201
apps/api/src/modules/internal/tech-step-worker.service.ts
Normal file
201
apps/api/src/modules/internal/tech-step-worker.service.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 { ErrorCode, addPlanningItemSchema, getPlanningByDateSchema } from "@batch-cooking/shared";
|
||||
import { addPlanningItemSchema, ErrorCode, 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";
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export async function getPlanningForDate(
|
|||
houseId: number | null,
|
||||
date: DateTime,
|
||||
): Promise<PlanningView | null> {
|
||||
try {
|
||||
if (houseId === null) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -70,6 +71,13 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -88,14 +96,24 @@ 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 prisma.planning.create({
|
||||
data: { houseId, startDate, finishDate: weekStart.plus({ days: 6 }).toJSDate() },
|
||||
return await prisma.planning.create({
|
||||
data: {
|
||||
houseId,
|
||||
startDate,
|
||||
finishDate: weekStart.plus({ days: 6 }).toJSDate(),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see getPlanningForDate()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -119,6 +137,7 @@ 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");
|
||||
}
|
||||
|
|
@ -145,6 +164,9 @@ export async function addPlanningItem(
|
|||
portions: item.portions,
|
||||
recipe: item.recipe,
|
||||
};
|
||||
} catch (err) {
|
||||
throw err; // see getPlanningForDate()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -156,6 +178,7 @@ 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 },
|
||||
|
|
@ -164,4 +187,7 @@ 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,18 @@ import { prisma } from "../../db/prisma.js";
|
|||
* just to read it.
|
||||
*/
|
||||
export async function getPreferences(userProfileId: number): Promise<PreferencesView> {
|
||||
const preferences = await prisma.userPreference.findUnique({ where: { userProfileId } });
|
||||
try {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -22,10 +32,14 @@ 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ 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) {
|
||||
|
|
@ -26,15 +27,26 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -49,6 +61,7 @@ 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 } },
|
||||
|
|
@ -73,15 +86,22 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -95,6 +115,7 @@ 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 } },
|
||||
|
|
@ -112,11 +133,19 @@ 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,506 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import {
|
||||
ErrorCode,
|
||||
createRecipeSchema,
|
||||
ErrorCode,
|
||||
listRecipesSchema,
|
||||
submitTechStepCorrectionSchema,
|
||||
updateRecipeSchema,
|
||||
} from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
|
|
@ -17,6 +18,10 @@ 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();
|
||||
|
|
@ -30,6 +35,15 @@ 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,
|
||||
|
|
@ -109,3 +123,29 @@ 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));
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,18 +14,46 @@ import {
|
|||
} from "@batch-cooking/shared";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
import { loadTechStepMappingRules, matchTechStepSpans } from "../../lib/tech-step-matcher.js";
|
||||
import {
|
||||
type TechStepMatch,
|
||||
techStepClassifier,
|
||||
} from "../../lib/recipe-matching/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 `loadTechStepMappingRules` for why the locale is
|
||||
// a parameter rather than baked into that module.
|
||||
// `tech-step-matcher.ts`'s `TechStepClassifierService` 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: {
|
||||
|
|
@ -37,26 +65,36 @@ function recipeInclude(viewerId: number) {
|
|||
unit: true,
|
||||
},
|
||||
},
|
||||
steps: {
|
||||
orderBy: { order: "asc" },
|
||||
include: { techSteps: { orderBy: { order: "asc" }, include: { techStep: true } } },
|
||||
utensils: { include: { utensil: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
diets: { include: { diet: true } },
|
||||
favoritedBy: { where: { userProfileId: viewerId } },
|
||||
} satisfies Prisma.RecipeInclude;
|
||||
}
|
||||
|
||||
type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType<typeof recipeInclude> }>;
|
||||
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
||||
type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
|
||||
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"];
|
||||
|
||||
/** 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 `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 `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 {
|
||||
/** 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 {
|
||||
return {
|
||||
id: ingredient.id,
|
||||
key: ingredient.key,
|
||||
|
|
@ -109,21 +147,57 @@ 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 this
|
||||
* column existed, not yet recomputed by a resave — see the schema doc
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
function toStepTechStepViews(
|
||||
export function toStepTechStepViews(
|
||||
techSteps: RecipeWithDetails["steps"][number]["techSteps"],
|
||||
): StepTechStepView[] {
|
||||
const views: StepTechStepView[] = [];
|
||||
for (const stepTechStep of techSteps) {
|
||||
if (stepTechStep.start === null || stepTechStep.end === null) continue;
|
||||
const { start, end, contextStart, contextEnd, techStep, source, ingredients, utensils } =
|
||||
stepTechStep;
|
||||
if (start === null || end === null) continue;
|
||||
views.push({
|
||||
techStep: { id: stepTechStep.techStep.id, key: stepTechStep.techStep.key },
|
||||
start: stepTechStep.start,
|
||||
end: stepTechStep.end,
|
||||
techStep: { id: techStep.id, key: techStep.key },
|
||||
start,
|
||||
end,
|
||||
// `source` is a plain DB `String`, not a Prisma enum (see
|
||||
// `StepTechStep`'s schema doc comment) — narrowed here rather than
|
||||
// trusting the column's own type, so a value this app never wrote
|
||||
// (a manual DB edit, a future migration gone wrong) degrades to the
|
||||
// safer "auto" reading instead of surfacing an invalid
|
||||
// `StepTechStepView.source` to the frontend.
|
||||
source: source === "manual" ? "manual" : "auto",
|
||||
...(contextStart !== null && contextEnd !== null ? { contextStart, contextEnd } : {}),
|
||||
ingredients: ingredients.map((stepTechStepIngredient) => ({
|
||||
ingredient: toIngredientView(stepTechStepIngredient.ingredient),
|
||||
quantity:
|
||||
stepTechStepIngredient.quantity === null ? null : Number(stepTechStepIngredient.quantity),
|
||||
unit: stepTechStepIngredient.unit === null ? null : toUnitView(stepTechStepIngredient.unit),
|
||||
start: stepTechStepIngredient.start,
|
||||
end: stepTechStepIngredient.end,
|
||||
// Same narrowing posture as the technique's own `source` above.
|
||||
source: stepTechStepIngredient.source === "manual" ? "manual" : "auto",
|
||||
})),
|
||||
utensils: utensils.map((stepTechStepUtensil) => ({
|
||||
utensil: { id: stepTechStepUtensil.utensil.id, key: stepTechStepUtensil.utensil.key },
|
||||
start: stepTechStepUtensil.start,
|
||||
end: stepTechStepUtensil.end,
|
||||
source: stepTechStepUtensil.source === "manual" ? "manual" : "auto",
|
||||
})),
|
||||
});
|
||||
}
|
||||
return views;
|
||||
|
|
@ -157,7 +231,11 @@ 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 {
|
||||
|
|
@ -201,6 +279,7 @@ 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 } } },
|
||||
|
|
@ -217,16 +296,29 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -241,13 +333,20 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -283,6 +382,7 @@ 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) {
|
||||
|
|
@ -298,11 +398,15 @@ 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) {
|
||||
|
|
@ -329,6 +433,9 @@ export async function listRecipes(
|
|||
orderBy: { name: "asc" },
|
||||
});
|
||||
return recipes.map(toRecipeSummaryView);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -341,11 +448,15 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -365,7 +476,11 @@ export async function createRecipe(
|
|||
authorId: number,
|
||||
authorHouseId: number | null,
|
||||
): Promise<RecipeView> {
|
||||
return createRecipeInternal(input, authorId, authorHouseId, null);
|
||||
try {
|
||||
return await createRecipeInternal(input, authorId, authorHouseId, null);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -389,7 +504,41 @@ export async function createImportedRecipe(
|
|||
authorHouseId: number | null,
|
||||
source: { sourceId: number; externalId: string; locale: string },
|
||||
): Promise<RecipeView> {
|
||||
return createRecipeInternal(input, authorId, authorHouseId, source);
|
||||
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),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async function createRecipeInternal(
|
||||
|
|
@ -398,10 +547,17 @@ 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);
|
||||
const techStepMappings = await loadTechStepMappingRules(
|
||||
// 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,
|
||||
source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
|
||||
);
|
||||
|
||||
|
|
@ -424,16 +580,34 @@ async function createRecipeInternal(
|
|||
})),
|
||||
},
|
||||
steps: {
|
||||
create: input.steps.map((step, index) => ({
|
||||
create: stepsWithTechSteps.map(({ step, matches }, index) => ({
|
||||
description: step.description,
|
||||
picture: step.picture ?? null,
|
||||
order: index,
|
||||
techSteps: {
|
||||
create: matchTechStepSpans(step.description, techStepMappings).map((match, order) => ({
|
||||
create: matches.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,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
},
|
||||
})),
|
||||
|
|
@ -443,6 +617,9 @@ async function createRecipeInternal(
|
|||
include: recipeInclude(authorId),
|
||||
});
|
||||
return toRecipeView(created);
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -464,11 +641,12 @@ 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 techStepMappings = await loadTechStepMappingRules(DEFAULT_TECH_STEP_LOCALE);
|
||||
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }),
|
||||
|
|
@ -490,19 +668,19 @@ export async function updateRecipe(
|
|||
})),
|
||||
},
|
||||
steps: {
|
||||
create: input.steps.map((step, index) => ({
|
||||
create: stepsWithTechSteps.map(({ step, matches }, index) => ({
|
||||
description: step.description,
|
||||
picture: step.picture ?? null,
|
||||
order: index,
|
||||
techSteps: {
|
||||
create: matchTechStepSpans(step.description, techStepMappings).map(
|
||||
(match, order) => ({
|
||||
create: matches.map((match, order) => ({
|
||||
techStepId: match.techStepId,
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
contextStart: match.contextStart,
|
||||
contextEnd: match.contextEnd,
|
||||
order,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
},
|
||||
})),
|
||||
},
|
||||
|
|
@ -512,6 +690,9 @@ export async function updateRecipe(
|
|||
]);
|
||||
|
||||
return toRecipeView(await findRecipeOrThrow(id, viewerId));
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -527,9 +708,12 @@ 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,
|
||||
|
|
@ -539,6 +723,9 @@ export async function deleteRecipe(
|
|||
}
|
||||
|
||||
await prisma.recipe.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -552,20 +739,32 @@ 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> {
|
||||
await prisma.recipeFavorite.deleteMany({ where: { userProfileId: viewerId, recipeId: id } });
|
||||
try {
|
||||
await prisma.recipeFavorite.deleteMany({
|
||||
where: { userProfileId: viewerId, recipeId: id },
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -581,14 +780,19 @@ 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),
|
||||
|
|
@ -597,6 +801,9 @@ 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`). */
|
||||
|
|
@ -605,6 +812,7 @@ 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`);
|
||||
|
|
@ -612,10 +820,14 @@ 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 } },
|
||||
|
|
@ -630,10 +842,14 @@ 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 } },
|
||||
|
|
@ -642,12 +858,20 @@ 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(", ")}`);
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.UNIT_NOT_FOUND,
|
||||
`Unit(s) not found: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/** 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({
|
||||
|
|
@ -657,6 +881,13 @@ 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(", ")}`);
|
||||
throw new HttpError(
|
||||
404,
|
||||
ErrorCode.DIET_NOT_FOUND,
|
||||
`Diet(s) not found: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
getSources,
|
||||
getTechSteps,
|
||||
getUnits,
|
||||
getUtensils,
|
||||
} from "./reference.service.js";
|
||||
|
||||
/**
|
||||
|
|
@ -55,6 +56,13 @@ referenceRouter.get(
|
|||
}),
|
||||
);
|
||||
|
||||
referenceRouter.get(
|
||||
"/utensils",
|
||||
wrapAsyncHandler(async (_req, res) => {
|
||||
res.status(200).json(await getUtensils());
|
||||
}),
|
||||
);
|
||||
|
||||
referenceRouter.get(
|
||||
"/sources",
|
||||
wrapAsyncHandler(async (_req, res) => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
SourceView,
|
||||
TechStepView,
|
||||
UnitView,
|
||||
UtensilView,
|
||||
} from "@batch-cooking/shared";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
|
||||
|
|
@ -17,7 +18,15 @@ import { prisma } from "../../db/prisma.js";
|
|||
* client-side (`apps/web`'s `locales/fr/translation.json`).
|
||||
*/
|
||||
export async function getDiets(): Promise<DietView[]> {
|
||||
return prisma.diet.findMany({ orderBy: { key: "asc" } });
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -28,6 +37,7 @@ 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" } },
|
||||
|
|
@ -37,6 +47,9 @@ export async function getAllergies(): Promise<AllergyView[]> {
|
|||
key: allergy.category.key,
|
||||
kind: allergy.category.kind,
|
||||
}));
|
||||
} catch (err) {
|
||||
throw err; // see getDiets()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -47,6 +60,7 @@ 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,
|
||||
|
|
@ -54,6 +68,9 @@ export async function getUnits(): Promise<UnitView[]> {
|
|||
type: unit.type,
|
||||
toBaseFactor: Number(unit.toBaseFactor),
|
||||
}));
|
||||
} catch (err) {
|
||||
throw err; // see getDiets()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -62,7 +79,24 @@ export async function getUnits(): Promise<UnitView[]> {
|
|||
* `TECH_STEPS`). Not consumed by the recipe UI yet — see {@link TechStepView}.
|
||||
*/
|
||||
export async function getTechSteps(): Promise<TechStepView[]> {
|
||||
return prisma.techStep.findMany({ orderBy: { key: "asc" } });
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -74,13 +108,23 @@ export async function getTechSteps(): Promise<TechStepView[]> {
|
|||
* `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 prisma.source.findMany({
|
||||
select: { id: true, key: true, name: true, official: true, iconUrl: true },
|
||||
return await 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -91,6 +135,7 @@ 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 } } } },
|
||||
|
|
@ -110,6 +155,12 @@ 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
36
apps/api/src/modules/shopping-list/shopping-list.routes.ts
Normal file
36
apps/api/src/modules/shopping-list/shopping-list.routes.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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);
|
||||
}),
|
||||
);
|
||||
154
apps/api/src/modules/shopping-list/shopping-list.service.ts
Normal file
154
apps/api/src/modules/shopping-list/shopping-list.service.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { ErrorCode, browseSourceSchema, createRecipeSchema } from "@batch-cooking/shared";
|
||||
import { browseSourceSchema, createRecipeSchema, ErrorCode } 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";
|
||||
|
|
|
|||
|
|
@ -11,19 +11,23 @@ import {
|
|||
import { prisma } from "../../db/prisma.js";
|
||||
import { findImportedRecipeIds } from "../../db/recipe-source-sync.js";
|
||||
import {
|
||||
type IngredientMatchEntry,
|
||||
type UnitMatchEntry,
|
||||
loadIngredientCatalog,
|
||||
loadUnitCatalog,
|
||||
} 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";
|
||||
} 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";
|
||||
import { getHouseSourceIds } from "../house/house.service.js";
|
||||
import { createImportedRecipe } from "../recipe/recipe.service.js";
|
||||
import { getIngredients, getUnits } from "../reference/reference.service.js";
|
||||
import { getIngredients, getUnits, getUtensils } from "../reference/reference.service.js";
|
||||
|
||||
/**
|
||||
* Browsing, previewing, and importing a household's *enabled* external
|
||||
|
|
@ -55,8 +59,11 @@ 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,
|
||||
|
|
@ -73,6 +80,13 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -88,8 +102,12 @@ 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,
|
||||
|
|
@ -109,6 +127,9 @@ export async function browseSource(
|
|||
})),
|
||||
nextCursor: result.nextCursor,
|
||||
};
|
||||
} catch (err) {
|
||||
throw err; // see assertSourceEnabled()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -118,10 +139,13 @@ 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 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.
|
||||
* 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.
|
||||
*
|
||||
* @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.
|
||||
|
|
@ -132,6 +156,7 @@ export async function previewSourceItem(
|
|||
externalId: string,
|
||||
houseId: number | null,
|
||||
): Promise<RecipeImportDraftView> {
|
||||
try {
|
||||
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
|
||||
|
||||
let parsed: ReturnType<typeof adapter.parse>;
|
||||
|
|
@ -145,10 +170,16 @@ export async function previewSourceItem(
|
|||
throw err;
|
||||
}
|
||||
|
||||
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[]>([]),
|
||||
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),
|
||||
prisma.techStep.findMany({ select: { id: true, key: true } }),
|
||||
]);
|
||||
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
|
||||
|
|
@ -157,12 +188,26 @@ export async function previewSourceItem(
|
|||
parsed.ingredients,
|
||||
ingredientCatalog,
|
||||
unitCatalog,
|
||||
adapter.locale,
|
||||
);
|
||||
const [ingredientViews, unitViews] = await Promise.all([getIngredients(), getUnits()]);
|
||||
const [ingredientViews, unitViews, utensilViews] = await Promise.all([
|
||||
getIngredients(),
|
||||
getUnits(),
|
||||
getUtensils(),
|
||||
]);
|
||||
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]));
|
||||
|
||||
const ingredients: DraftRecipeIngredientView[] = translatedIngredients.map((ingredient) => ({
|
||||
// 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) => ({
|
||||
rawText: ingredient.rawText,
|
||||
quantity: ingredient.quantity,
|
||||
ingredient:
|
||||
|
|
@ -172,12 +217,52 @@ export async function previewSourceItem(
|
|||
unit: ingredient.unitId !== null ? (unitById.get(ingredient.unitId) ?? null) : null,
|
||||
}));
|
||||
|
||||
const steps: DraftRecipeStepView[] = parsed.steps.map((step) => ({
|
||||
const steps: DraftRecipeStepView[] = stepsWithTechStepMatches.map(({ step, matches }) => ({
|
||||
description: step.description,
|
||||
picture: step.picture,
|
||||
techSteps: matchTechStepSpans(step.description, techStepMappings).flatMap((match) => {
|
||||
techSteps: matches.flatMap((match) => {
|
||||
const techStep = techStepById.get(match.techStepId);
|
||||
return techStep ? [{ techStep, start: match.start, end: match.end }] : [];
|
||||
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 }]
|
||||
: [];
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
@ -192,6 +277,9 @@ export async function previewSourceItem(
|
|||
ingredients,
|
||||
steps,
|
||||
};
|
||||
} catch (err) {
|
||||
throw err; // see assertSourceEnabled()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -221,6 +309,7 @@ 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]);
|
||||
|
|
@ -232,9 +321,12 @@ export async function importSourceItem(
|
|||
);
|
||||
}
|
||||
|
||||
return createImportedRecipe(input, authorId, authorHouseId, {
|
||||
return await createImportedRecipe(input, authorId, authorHouseId, {
|
||||
sourceId,
|
||||
externalId,
|
||||
locale: adapter.locale,
|
||||
});
|
||||
} catch (err) {
|
||||
throw err; // see assertSourceEnabled()'s catch comment above
|
||||
}
|
||||
}
|
||||
|
|
|
|||
123
apps/api/src/scripts/backfill-tech-steps.ts
Normal file
123
apps/api/src/scripts/backfill-tech-steps.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
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);
|
||||
});
|
||||
}
|
||||
101
apps/api/src/scripts/calibrate-tech-step-threshold.ts
Normal file
101
apps/api/src/scripts/calibrate-tech-step-threshold.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
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);
|
||||
});
|
||||
72
apps/api/src/scripts/list-pending-training-suggestions.ts
Normal file
72
apps/api/src/scripts/list-pending-training-suggestions.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
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);
|
||||
});
|
||||
103
apps/api/src/scripts/retrain-tech-steps.ts
Normal file
103
apps/api/src/scripts/retrain-tech-steps.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
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);
|
||||
});
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
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
|
||||
|
|
@ -7,8 +9,47 @@ 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, () => {
|
||||
console.log(`API listening on http://localhost:${env.PORT}`);
|
||||
logger.info("API listening", { port: env.PORT, nodeEnv: env.NODE_ENV });
|
||||
});
|
||||
|
|
|
|||
425
apps/api/src/sources/750g.ts
Normal file
425
apps/api/src/sources/750g.ts
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
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+0000–U+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 (`'`/`'`) 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 `&eacute;`
|
||||
* (the `&` of an already-produced `é` got re-escaped to `&`)
|
||||
* rather than a plain `é` or a raw "é" (verified live, e.g.
|
||||
* "Pr&eacute;parez" on
|
||||
* https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm). One
|
||||
* pass turns that into `é` — 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
import { registerRecipeSource } from "../lib/recipe-source-registry.js";
|
||||
import { registerRecipeSource } from "../lib/recipe-sources/recipe-source-registry.js";
|
||||
import { sevenFiftyGAdapter } from "./750g.js";
|
||||
import { mangerBougerAdapter } from "./manger-bouger.js";
|
||||
import { marmitonAdapter } from "./marmiton.js";
|
||||
import { theMealDbAdapter } from "./the-meal-db.js";
|
||||
|
||||
/**
|
||||
* Registers every concrete, *browsable* `RecipeSourceAdapter` this app
|
||||
* ships with into the shared in-memory registry
|
||||
* (`recipe-source-registry.ts`) — currently just `theMealDbAdapter`.
|
||||
* 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`)
|
||||
* — `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter` and
|
||||
* `mangerBougerAdapter`. 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
|
||||
|
|
@ -21,15 +24,19 @@ 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) is deliberately **not**
|
||||
* `jsonLdRecipeAdapter` (json-ld-recipe.ts) itself is deliberately **not**
|
||||
* registered here — it's a generic schema.org-JSON-LD parser meant to be
|
||||
* 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.
|
||||
* 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").
|
||||
*/
|
||||
export function registerAllRecipeSources(): void {
|
||||
registerRecipeSource(theMealDbAdapter);
|
||||
registerRecipeSource(marmitonAdapter);
|
||||
registerRecipeSource(sevenFiftyGAdapter);
|
||||
registerRecipeSource(mangerBougerAdapter);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@ import type {
|
|||
ParsedRecipe,
|
||||
ParsedRecipeIngredient,
|
||||
RecipeSourceAdapter,
|
||||
} from "../lib/recipe-source-adapter.js";
|
||||
import { RecipeSourceFetchError, RecipeSourceParseError } from "../lib/recipe-source-errors.js";
|
||||
} from "../lib/recipe-sources/recipe-source-adapter.js";
|
||||
import {
|
||||
RecipeSourceFetchError,
|
||||
RecipeSourceParseError,
|
||||
} from "../lib/recipe-sources/recipe-source-errors.js";
|
||||
|
||||
const SOURCE_KEY = "jsonLdRecipe";
|
||||
|
||||
|
|
@ -41,8 +44,17 @@ 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`). */
|
||||
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`). 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[] {
|
||||
const blocks: unknown[] = [];
|
||||
for (const match of html.matchAll(JSON_LD_SCRIPT_PATTERN)) {
|
||||
try {
|
||||
|
|
@ -164,8 +176,22 @@ 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,
|
||||
|
|
@ -179,10 +205,19 @@ export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ html: string; url: strin
|
|||
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);
|
||||
|
|
@ -194,6 +229,9 @@ export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ html: string; url: strin
|
|||
}
|
||||
const html = await response.text();
|
||||
return { html, url };
|
||||
} catch (err) {
|
||||
throw err; // see list()'s catch comment above
|
||||
}
|
||||
},
|
||||
|
||||
parse({ html, url }): ParsedRecipe {
|
||||
|
|
|
|||
355
apps/api/src/sources/manger-bouger.ts
Normal file
355
apps/api/src/sources/manger-bouger.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
206
apps/api/src/sources/marmiton.ts
Normal file
206
apps/api/src/sources/marmiton.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -3,8 +3,11 @@ import type {
|
|||
RecipeSourceAdapter,
|
||||
RecipeSourceListParams,
|
||||
RecipeSourceListResult,
|
||||
} from "../lib/recipe-source-adapter.js";
|
||||
import { RecipeSourceFetchError, RecipeSourceParseError } from "../lib/recipe-source-errors.js";
|
||||
} from "../lib/recipe-sources/recipe-source-adapter.js";
|
||||
import {
|
||||
RecipeSourceFetchError,
|
||||
RecipeSourceParseError,
|
||||
} from "../lib/recipe-sources/recipe-source-errors.js";
|
||||
|
||||
const SOURCE_KEY = "theMealDb";
|
||||
|
||||
|
|
@ -35,6 +38,7 @@ interface TheMealDbMealsResponse {
|
|||
}
|
||||
|
||||
async function fetchTheMealDb<T>(path: string): Promise<T> {
|
||||
try {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${path}`);
|
||||
|
|
@ -49,7 +53,13 @@ async function fetchTheMealDb<T>(path: string): Promise<T> {
|
|||
`TheMealDB responded ${response.status} (${path})`,
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function detailUrl(idMeal: string): string {
|
||||
|
|
@ -82,6 +92,7 @@ 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)}`,
|
||||
|
|
@ -98,15 +109,22 @@ 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 {
|
||||
|
|
@ -129,10 +147,17 @@ 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(
|
||||
|
|
|
|||
40
apps/api/test-support/mocha-root-hooks.ts
Normal file
40
apps/api/test-support/mocha-root-hooks.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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_mapping", "tech_step",
|
||||
"recipe_ingredient", "step_tech_step", "step", "tech_step",
|
||||
"recipe", "ingredients", "sources", "unit",
|
||||
"user_profiles", "diet", "house"
|
||||
RESTART IDENTITY CASCADE;
|
||||
|
|
|
|||
|
|
@ -6,8 +6,11 @@ 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-source-adapter.js";
|
||||
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.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";
|
||||
|
||||
function buildSignupPayload(): SignupInput {
|
||||
|
|
|
|||
|
|
@ -1,218 +0,0 @@
|
|||
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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
306
apps/api/test/internal/tech-step-worker.routes.test.ts
Normal file
306
apps/api/test/internal/tech-step-worker.routes.test.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
105
apps/api/test/logger.service.test.ts
Normal file
105
apps/api/test/logger.service.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
459
apps/api/test/recipe-matching/ingredient-matcher.test.ts
Normal file
459
apps/api/test/recipe-matching/ingredient-matcher.test.ts
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +1,22 @@
|
|||
import { expect } from "chai";
|
||||
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 { prisma } from "../../src/db/prisma.js";
|
||||
import type {
|
||||
IngredientMatchEntry,
|
||||
UnitMatchEntry,
|
||||
} from "../../src/lib/recipe-matching/ingredient-matcher.js";
|
||||
import {
|
||||
mergeDuplicateIngredients,
|
||||
type TranslatedRecipeIngredient,
|
||||
translateRecipe,
|
||||
translateRecipeIngredients,
|
||||
translateRecipeSteps,
|
||||
} from "../src/lib/recipe-translation.js";
|
||||
import type { TechStepMappingRule } from "../src/lib/tech-step-matcher.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
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";
|
||||
|
||||
/** 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 {
|
||||
|
|
@ -27,56 +35,67 @@ 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", () => {
|
||||
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,
|
||||
};
|
||||
let simmerId: number;
|
||||
let preheatId: number;
|
||||
let meltId: number;
|
||||
|
||||
it("declares each step's technique sequence, preserving order", () => {
|
||||
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 () => {
|
||||
const recipe = buildParsedRecipe([
|
||||
"Préchauffer la poêle, puis faire fondre le beurre",
|
||||
"Servir immédiatement",
|
||||
"Faire mijoter à feu doux",
|
||||
]);
|
||||
|
||||
const translated = translateRecipeSteps(recipe, [simmer, preheat, melt]);
|
||||
const translated = await translateRecipeSteps(recipe, "fr");
|
||||
|
||||
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[2, 3], [], [1]]);
|
||||
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([
|
||||
[preheatId, meltId],
|
||||
[],
|
||||
[simmerId],
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves description/picture untouched on each step", () => {
|
||||
const recipe = buildParsedRecipe(["Faire mijoter à feu doux", "Servir"]);
|
||||
it("leaves description/picture untouched on each step", async () => {
|
||||
const recipe = buildParsedRecipe(["Faire mijoter à feu doux", "Servir immédiatement"]);
|
||||
|
||||
const translated = translateRecipeSteps(recipe, [simmer]);
|
||||
const translated = await translateRecipeSteps(recipe, "fr");
|
||||
|
||||
expect(translated.steps[0]).to.deep.equal({
|
||||
description: "Faire mijoter à feu doux",
|
||||
picture: "https://example.test/step1.jpg",
|
||||
techStepIds: [1],
|
||||
techStepIds: [simmerId],
|
||||
});
|
||||
expect(translated.steps[1]).to.deep.equal({
|
||||
description: "Servir",
|
||||
description: "Servir immédiatement",
|
||||
picture: null,
|
||||
techStepIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes every other field through unchanged", () => {
|
||||
const recipe = buildParsedRecipe(["Servir"]);
|
||||
it("passes every other field through unchanged", async () => {
|
||||
const recipe = buildParsedRecipe(["Servir immédiatement"]);
|
||||
|
||||
const translated = translateRecipeSteps(recipe, []);
|
||||
const translated = await translateRecipeSteps(recipe, "fr");
|
||||
|
||||
expect(translated.name).to.equal(recipe.name);
|
||||
expect(translated.description).to.equal(recipe.description);
|
||||
|
|
@ -85,28 +104,31 @@ 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", () => {
|
||||
const recipe = buildParsedRecipe(["Servir"]);
|
||||
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"]);
|
||||
|
||||
const translated = translateRecipeSteps(recipe, []);
|
||||
const translated = await translateRecipeSteps(recipe, "fr");
|
||||
|
||||
expect(translated.ingredients).to.deep.equal([
|
||||
{ ...recipe.ingredients[0], ingredientId: null, unitId: null },
|
||||
]);
|
||||
});
|
||||
|
||||
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"]);
|
||||
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",
|
||||
]);
|
||||
|
||||
const translated = translateRecipeSteps(recipe, []);
|
||||
const translated = await translateRecipeSteps(recipe, "fr");
|
||||
|
||||
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[], []]);
|
||||
});
|
||||
|
||||
it("handles a recipe with no steps without error", () => {
|
||||
it("handles a recipe with no steps without error", async () => {
|
||||
const recipe = buildParsedRecipe([]);
|
||||
|
||||
const translated = translateRecipeSteps(recipe, [simmer]);
|
||||
const translated = await translateRecipeSteps(recipe, "fr");
|
||||
|
||||
expect(translated.steps).to.deep.equal([]);
|
||||
});
|
||||
|
|
@ -196,6 +218,32 @@ 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" })],
|
||||
|
|
@ -217,6 +265,111 @@ 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();
|
||||
|
|
@ -301,7 +454,47 @@ describe("recipe-translation", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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 () => {
|
||||
const recipe: ParsedRecipe = {
|
||||
...buildParsedRecipe(["Faire mijoter à feu doux"]),
|
||||
ingredients: [
|
||||
|
|
@ -309,12 +502,12 @@ describe("recipe-translation", () => {
|
|||
],
|
||||
};
|
||||
|
||||
const translated = await translateRecipe(recipe, "fr");
|
||||
const translated = await translateRecipe(recipe, "de");
|
||||
|
||||
expect(translated.ingredients).to.deep.equal([
|
||||
{
|
||||
rawText: "1 cup onions, chopped",
|
||||
quantity: null,
|
||||
quantity: 1,
|
||||
unit: null,
|
||||
name: "onions",
|
||||
ingredientId: null,
|
||||
37
apps/api/test/recipe-matching/tech-step-eval.test.ts
Normal file
37
apps/api/test/recipe-matching/tech-step-eval.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
427
apps/api/test/recipe-matching/tech-step-matcher.test.ts
Normal file
427
apps/api/test/recipe-matching/tech-step-matcher.test.ts
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
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.
|
||||
});
|
||||
});
|
||||
});
|
||||
341
apps/api/test/recipe-sources/750g.test.ts
Normal file
341
apps/api/test/recipe-sources/750g.test.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
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&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 `'` 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 'fin'"],
|
||||
"recipeInstructions": [
|
||||
{"@type": "HowToStep", "text": "Pr&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 'reinettes'</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;)", () => {
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { expect } from "chai";
|
||||
import { RecipeSourceFetchError, RecipeSourceParseError } from "../src/lib/recipe-source-errors.js";
|
||||
import { jsonLdRecipeAdapter } from "../src/sources/json-ld-recipe.js";
|
||||
import {
|
||||
RecipeSourceFetchError,
|
||||
RecipeSourceParseError,
|
||||
} from "../../src/lib/recipe-sources/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) {
|
||||
301
apps/api/test/recipe-sources/manger-bouger.test.ts
Normal file
301
apps/api/test/recipe-sources/manger-bouger.test.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
226
apps/api/test/recipe-sources/marmiton.test.ts
Normal file
226
apps/api/test/recipe-sources/marmiton.test.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -2,12 +2,15 @@ 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-source-adapter.js";
|
||||
import { clearRecipeSources, registerRecipeSource } from "../src/lib/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-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";
|
||||
|
||||
/** See `recipe.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
|
||||
function buildSignupPayload(): SignupInput {
|
||||
|
|
@ -5,19 +5,19 @@ import type {
|
|||
RecipeSourceListItem,
|
||||
RecipeSourceListParams,
|
||||
RecipeSourceListResult,
|
||||
} from "../src/lib/recipe-source-adapter.js";
|
||||
import { markAlreadyImported } from "../src/lib/recipe-source-adapter.js";
|
||||
} from "../../src/lib/recipe-sources/recipe-source-adapter.js";
|
||||
import { markAlreadyImported } from "../../src/lib/recipe-sources/recipe-source-adapter.js";
|
||||
import {
|
||||
RecipeSourceError,
|
||||
RecipeSourceFetchError,
|
||||
RecipeSourceParseError,
|
||||
} from "../src/lib/recipe-source-errors.js";
|
||||
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
|
||||
import {
|
||||
clearRecipeSources,
|
||||
getRecipeSource,
|
||||
listRecipeSources,
|
||||
registerRecipeSource,
|
||||
} from "../src/lib/recipe-source-registry.js";
|
||||
} from "../../src/lib/recipe-sources/recipe-source-registry.js";
|
||||
|
||||
interface FakeRawRecipe {
|
||||
externalId: string;
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { expect } from "chai";
|
||||
import { RecipeSourceFetchError, RecipeSourceParseError } from "../src/lib/recipe-source-errors.js";
|
||||
import { type TheMealDbMeal, theMealDbAdapter } from "../src/sources/the-meal-db.js";
|
||||
import {
|
||||
RecipeSourceFetchError,
|
||||
RecipeSourceParseError,
|
||||
} from "../../src/lib/recipe-sources/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
|
||||
|
|
@ -154,6 +157,20 @@ 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,
|
||||
|
|
@ -6,8 +6,11 @@ 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-source-adapter.js";
|
||||
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.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";
|
||||
|
||||
/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */
|
||||
|
|
@ -60,7 +63,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/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/recipe-matching/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 },
|
||||
|
|
@ -568,6 +571,32 @@ 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");
|
||||
|
|
@ -747,8 +776,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");
|
||||
|
||||
|
|
@ -860,7 +889,7 @@ describe("Recipes", () => {
|
|||
});
|
||||
|
||||
it("rejects an edit from anyone other than the recipe's author with 403 NOT_RECIPE_AUTHOR", async () => {
|
||||
const { agent, profileId } = await signup();
|
||||
const { profileId } = await signup();
|
||||
const { agent: otherAgent } = await signup();
|
||||
const tomate = await ingredientId("tomato");
|
||||
const piece = await unitId("piece");
|
||||
|
|
|
|||
550
apps/api/test/recipe/recipe-tech-step-correction.test.ts
Normal file
550
apps/api/test/recipe/recipe-tech-step-correction.test.ts
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -3,9 +3,12 @@ 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 } 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 { 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 { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */
|
||||
|
|
@ -134,7 +137,9 @@ describe("Reference data", () => {
|
|||
const res = await request(app).get("/reference/tech-steps");
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.have.length(26);
|
||||
// `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.map((t: { key: string }) => t.key)).to.include("simmer");
|
||||
expect(res.body[0]).to.have.keys(["id", "key"]);
|
||||
});
|
||||
|
|
@ -146,15 +151,38 @@ describe("Reference data", () => {
|
|||
expect(keys).to.deep.equal([...keys].sort());
|
||||
});
|
||||
|
||||
it("reseeding is idempotent — no duplicate techniques or mappings", async () => {
|
||||
it("reseeding is idempotent — no duplicate techniques", 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(26);
|
||||
// 26 techniques × one "fr" + one "en" mapping each.
|
||||
expect(await prisma.techStepMapping.count()).to.equal(52);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
336
apps/api/test/shopping-list.test.ts
Normal file
336
apps/api/test/shopping-list.test.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -3,8 +3,8 @@ import {
|
|||
clearRecipeSources,
|
||||
getRecipeSource,
|
||||
listRecipeSources,
|
||||
} from "../src/lib/recipe-source-registry.js";
|
||||
import { registerAllRecipeSources } from "../src/sources/index.js";
|
||||
} from "../../src/lib/recipe-sources/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,6 +25,33 @@ 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();
|
||||
|
||||
|
|
@ -3,18 +3,21 @@ 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-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";
|
||||
} 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";
|
||||
|
||||
/** See `recipe.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
|
||||
function buildSignupPayload(): SignupInput {
|
||||
|
|
@ -78,6 +81,94 @@ 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 } });
|
||||
|
|
@ -262,6 +353,59 @@ 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", () => {
|
||||
|
|
@ -357,6 +501,23 @@ 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
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
277
apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx
Normal file
277
apps/web/cypress/component/TechStepCorrectionPopover.cy.tsx
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,47 +1,63 @@
|
|||
import type { StepTechStepView } from "@batch-cooking/shared";
|
||||
import { splitDescriptionByTechSteps } from "../../src/features/recipes/highlight-tech-steps";
|
||||
import { splitDescriptionByTechSteps } from "../../src/features/recipes/steps/highlight-tech-steps";
|
||||
|
||||
// Pure logic, no DOM/mount needed — reuses the component-test runner
|
||||
// (Cypress's Mocha/Chai, same as CheckboxOption.cy.tsx) purely for its
|
||||
// `expect`, not for rendering. `.cy.tsx` (not `.cy.ts`) only because that's
|
||||
// what `cypress.config.ts`'s component `specPattern` looks for.
|
||||
|
||||
function techStep(key: string, id: number, start: number, end: number): StepTechStepView {
|
||||
return { techStep: { id, key }, start, end };
|
||||
/** 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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
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 },
|
||||
{ text: "Servir immédiatement", techStep: null, isKeyword: false, source: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("splits a single match into before/match/after segments", () => {
|
||||
// "Faire mijoter à feu doux" — "mijoter" is [6, 13).
|
||||
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.
|
||||
const result = splitDescriptionByTechSteps("Faire mijoter à feu doux", [
|
||||
techStep("simmer", 1, 6, 13),
|
||||
]);
|
||||
expect(result).to.deep.equal([
|
||||
{ text: "Faire ", techStep: null },
|
||||
{ text: "mijoter", techStep: { id: 1, key: "simmer" } },
|
||||
{ text: " à feu doux", techStep: null },
|
||||
{ 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 },
|
||||
]);
|
||||
});
|
||||
|
||||
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" } },
|
||||
{ text: " les oignons", techStep: null },
|
||||
{ text: "Hacher", techStep: { id: 2, key: "chop" }, isKeyword: true, source: "auto" },
|
||||
{ text: " les oignons", techStep: null, isKeyword: false, source: 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 },
|
||||
{ text: "cuire", techStep: { id: 3, key: "cook" } },
|
||||
{ text: "Faire ", techStep: null, isKeyword: false, source: null },
|
||||
{ text: "cuire", techStep: { id: 3, key: "cook" }, isKeyword: true, source: "auto" },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -53,34 +69,45 @@ 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" } });
|
||||
expect(result[0]).to.deep.equal({
|
||||
text: "Préchauffer",
|
||||
techStep: { id: 4, key: "preheat" },
|
||||
isKeyword: true,
|
||||
source: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
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 `start`, not trust the input order.
|
||||
// by position, 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);
|
||||
const matches = result.filter((s) => s.techStep !== null && s.isKeyword);
|
||||
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 }]);
|
||||
expect(result).to.deep.equal([
|
||||
{ text: "Cuire", techStep: null, isKeyword: false, source: 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 }]);
|
||||
expect(result).to.deep.equal([
|
||||
{ text: "Cuire", techStep: null, isKeyword: false, source: 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 }]);
|
||||
expect(result).to.deep.equal([
|
||||
{ text: "Cuire", techStep: null, isKeyword: false, source: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a later match that overlaps one already accepted", () => {
|
||||
|
|
@ -91,10 +118,133 @@ 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" } }]);
|
||||
expect(result).to.deep.equal([
|
||||
{ text: "Cuire au four", techStep: { id: 3, key: "bake" }, isKeyword: true, source: "auto" },
|
||||
]);
|
||||
});
|
||||
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -147,11 +147,13 @@ 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");
|
||||
});
|
||||
|
||||
it("centers the Liste de courses stub, with equal space on both sides", () => {
|
||||
cy.intercept("GET", /\/shopping-list\?/, {
|
||||
statusCode: 200,
|
||||
body: { startDate: "2026-08-17", finishDate: "2026-08-23", items: [] },
|
||||
});
|
||||
cy.visit("/liste-de-courses");
|
||||
assertCenteredColumn(".coming-soon-page", 640); // max-width: 40rem
|
||||
assertFillsContentWidth(".shopping-list-page");
|
||||
});
|
||||
|
||||
/** Fills `.app-content`'s available (padding-excluded) width, within a couple px of scrollbar/rounding slack. */
|
||||
|
|
@ -168,22 +170,6 @@ 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", () => {
|
||||
|
|
|
|||
|
|
@ -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 stub pages", () => {
|
||||
it("highlights the current section and navigates between pages", () => {
|
||||
cy.contains("nav a", "Planning").should("have.class", "active");
|
||||
|
||||
cy.contains("nav a", "Recettes").click();
|
||||
|
|
|
|||
|
|
@ -13,17 +13,26 @@ 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 reflects whatever's been added so far
|
||||
And the planning request returns nothing
|
||||
|
||||
Scenario: Adds a not-yet-imported source item to a planning slot, importing it on the way
|
||||
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
|
||||
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 "Sources"
|
||||
And I click the button "TheMealDB"
|
||||
And I click the source item "Fish Pie"
|
||||
And I click the link "Importer cette recette"
|
||||
And I click the button "Confirmer"
|
||||
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"
|
||||
|
|
@ -32,5 +41,19 @@ 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 URL should be the home page
|
||||
And the recipe picker dialog should be closed
|
||||
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
|
||||
|
|
|
|||
|
|
@ -13,13 +13,6 @@ 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: [] });
|
||||
});
|
||||
|
|
@ -49,6 +42,18 @@ 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,
|
||||
},
|
||||
|
|
@ -89,6 +94,76 @@ 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.
|
||||
|
|
@ -134,9 +209,7 @@ 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", (req) => {
|
||||
fishPiePlanned = true;
|
||||
req.reply({
|
||||
cy.intercept("POST", "**/planning/items", {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
id: 1,
|
||||
|
|
@ -145,39 +218,17 @@ Given("adding the imported recipe to the planning will succeed", () => {
|
|||
portions: 4,
|
||||
recipe: { id: 99, name: "Fish Pie" },
|
||||
},
|
||||
});
|
||||
}).as("addPlanningItem");
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
// 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");
|
||||
});
|
||||
|
||||
// The very first "+" in DOM order is Lundi's Petit-déjeuner cell (`MEALS`'s
|
||||
|
|
|
|||
|
|
@ -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 "Œuf" from the picker
|
||||
And I select the ingredient "Oeuf" 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 "Œuf"
|
||||
And the recipe should include the ingredient "Oeuf"
|
||||
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 "Œuf"
|
||||
Then the recipe should include the ingredient "Oeuf"
|
||||
And the ingredient's quantity should be "3"
|
||||
When I select the ingredient "Tomate" from the picker
|
||||
Then there should be 2 ingredient rows
|
||||
|
|
|
|||
|
|
@ -9,13 +9,12 @@ Feature: Browsing external recipe sources
|
|||
And the disliked ingredients list is empty
|
||||
And the planning request returns nothing
|
||||
|
||||
Scenario: Prompts to enable a source when the household hasn't enabled any
|
||||
Scenario: Shows no source tab 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"
|
||||
And I click the button "Sources"
|
||||
Then I should see "Aucune source n'est activée"
|
||||
Then I should not see "TheMealDB"
|
||||
|
||||
Scenario: Browses an enabled source, distinguishing already-imported items from new ones
|
||||
Given the recipe catalog contains nothing
|
||||
|
|
@ -24,7 +23,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 "Sources"
|
||||
And I click the button "TheMealDB"
|
||||
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
|
||||
|
|
@ -40,34 +39,42 @@ 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 "Sources"
|
||||
And I click the button "TheMealDB"
|
||||
And I click the source item "Fish Pie"
|
||||
Then the recipe detail panel heading should be "Fish Pie"
|
||||
Then the URL should include "/recettes/sources/theMealDb/9999"
|
||||
And the recipe detail panel heading should be "Fish Pie"
|
||||
And I should see the highlighted technique "Cuire"
|
||||
|
||||
Scenario: Reviews an import, resolving an unrecognized ingredient before confirming
|
||||
Scenario: Loads further pages automatically, with no "load more" button
|
||||
Given the recipe catalog contains nothing
|
||||
And the sources reference list has options
|
||||
And the household has enabled TheMealDB
|
||||
And browsing TheMealDB returns two pages of items
|
||||
When I visit "/recettes"
|
||||
And I click the button "TheMealDB"
|
||||
Then I should see the source item "Chicken Handi"
|
||||
And I should see the source item "Beef Wellington"
|
||||
And I should not see "Voir plus"
|
||||
|
||||
Scenario: Offers a retry when loading the next page fails
|
||||
Given the recipe catalog contains nothing
|
||||
And the sources reference list has options
|
||||
And the household has enabled TheMealDB
|
||||
And browsing TheMealDB's next page fails once, then succeeds
|
||||
When I visit "/recettes"
|
||||
And I click the button "TheMealDB"
|
||||
Then I should see the source item "Chicken Handi"
|
||||
And I should see a message to retry loading more
|
||||
When I click the button "Réessayer"
|
||||
Then I should see the source item "Beef Wellington"
|
||||
|
||||
Scenario: Deep-links straight to a not-yet-imported item's own page, with no import affordance at all
|
||||
Given the recipe catalog contains nothing
|
||||
And the sources reference list has options
|
||||
And the household has enabled TheMealDB
|
||||
And browsing TheMealDB returns some items
|
||||
And previewing TheMealDB item "9999" is available
|
||||
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"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -83,6 +83,81 @@ 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,
|
||||
|
|
@ -135,66 +210,9 @@ 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");
|
||||
});
|
||||
|
||||
// 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 }],
|
||||
});
|
||||
// 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");
|
||||
});
|
||||
|
||||
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
Loading…
Reference in a new issue