Compare commits
20 commits
feat/tech-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ec9b82e06 | |||
| f7aa696f55 | |||
| f19365e20e | |||
| 8a51f0bd6c | |||
| 32c7ed48c6 | |||
| 0ab30a4588 | |||
| 3c04efd16f | |||
| 7c423cc0fd | |||
| d58491e6f9 | |||
| 478914787f | |||
| 5ab9832131 | |||
|
|
eef5db92b5 | ||
|
|
550627919d | ||
|
|
bf58834aa9 | ||
|
|
109dde9c7b | ||
|
|
520e539fe6 | ||
|
|
ba3c978c25 | ||
|
|
88666f0ac5 | ||
|
|
5ea1026151 | ||
|
|
92bea914e8 |
155 changed files with 20130 additions and 2575 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
|
# over plain HTTP a Secure cookie is silently never sent back by the
|
||||||
# browser, so login "succeeds" but every subsequent request 401s.
|
# browser, so login "succeeds" but every subsequent request 401s.
|
||||||
# COOKIE_SECURE=false
|
# 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
|
||||||
|
|
|
||||||
93
.github/workflows/ci.yml
vendored
93
.github/workflows/ci.yml
vendored
|
|
@ -12,22 +12,32 @@ on:
|
||||||
push:
|
push:
|
||||||
|
|
||||||
env:
|
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.
|
# Test-only secret, never used outside CI — real deployments must set their own.
|
||||||
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
|
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:
|
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
|
# and reports as its own check, instead of the previous single chained
|
||||||
# "lint-and-test then e2e" pipeline.
|
# "lint-and-test then e2e" pipeline.
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: https://github.com/pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -45,34 +55,80 @@ jobs:
|
||||||
POSTGRES_PASSWORD: ci
|
POSTGRES_PASSWORD: ci
|
||||||
POSTGRES_DB: batchcooking_ci
|
POSTGRES_DB: batchcooking_ci
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5433:5432
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 5s
|
--health-interval 5s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: https://github.com/pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
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 install --frozen-lockfile
|
||||||
- run: pnpm --filter api exec prisma migrate deploy
|
- run: pnpm --filter api exec prisma migrate deploy
|
||||||
- run: pnpm --filter api test
|
- 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:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: https://github.com/pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -83,17 +139,17 @@ jobs:
|
||||||
e2e:
|
e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: https://github.com/pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Cache Cypress binary
|
- name: Cache Cypress binary
|
||||||
uses: actions/cache@v4
|
uses: https://github.com/actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/Cypress
|
path: ~/.cache/Cypress
|
||||||
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
|
@ -103,7 +159,10 @@ jobs:
|
||||||
# download (see apps/web's cypress caveat in the README) — install it
|
# download (see apps/web's cypress caveat in the README) — install it
|
||||||
# explicitly so `cypress run` finds it.
|
# explicitly so `cypress run` finds it.
|
||||||
- run: pnpm --filter web exec cypress install
|
- 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
|
# No dev server needed here — Cypress spins up its own Vite dev
|
||||||
# server internally for component testing (see cypress.config.ts's
|
# server internally for component testing (see cypress.config.ts's
|
||||||
# `component.devServer`), unlike `e2e` above which needs the real app
|
# `component.devServer`), unlike `e2e` above which needs the real app
|
||||||
|
|
|
||||||
16
.gitignore
vendored
16
.gitignore
vendored
|
|
@ -71,11 +71,12 @@ web_modules/
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.test.example
|
!.env.test.example
|
||||||
|
|
||||||
# node-nlp's default auto-save file (apps/api/src/lib/recipe-matching/
|
# Python virtualenvs/caches for services/tech-step-intent-service (this repo
|
||||||
# tech-step-matcher.ts explicitly disables autoSave/autoLoad, but this is a
|
# is otherwise all-Node — see that service's own .gitignore for the rest;
|
||||||
# belt-and-suspenders guard against it ever reappearing — a stale trained
|
# duplicated here too since some tooling only honors the repo-root file).
|
||||||
# model on disk must never silently shadow TECH_STEP_TRAINING_DATA).
|
services/tech-step-intent-service/.venv/
|
||||||
model.nlp
|
services/tech-step-intent-service/__pycache__/
|
||||||
|
services/tech-step-intent-service/.pytest_cache/
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
.cache
|
.cache
|
||||||
|
|
@ -157,3 +158,8 @@ tmp-mockups/
|
||||||
|
|
||||||
# IA
|
# IA
|
||||||
.claude/
|
.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/
|
||||||
|
|
|
||||||
32
README.md
32
README.md
|
|
@ -44,6 +44,8 @@ runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans
|
||||||
- Node.js 22 (voir `.nvmrc`)
|
- Node.js 22 (voir `.nvmrc`)
|
||||||
- pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle)
|
- pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle)
|
||||||
- Docker (pour Postgres en local)
|
- 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
|
## Installation
|
||||||
|
|
||||||
|
|
@ -100,6 +102,17 @@ pnpm --filter api exec prisma migrate dev
|
||||||
# techniques...) — automatique après `prisma migrate reset`, sinon à la main :
|
# techniques...) — automatique après `prisma migrate reset`, sinon à la main :
|
||||||
pnpm --filter api prisma:seed
|
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)
|
# Backend (http://localhost:3000)
|
||||||
pnpm dev:api
|
pnpm dev:api
|
||||||
|
|
||||||
|
|
@ -137,7 +150,7 @@ pnpm --filter web cy:run:component # tests de composant UI isolés (Cypress com
|
||||||
pnpm build # build de tous les workspaces
|
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` — ce dernier lance aussi `cy:run:component`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
|
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`)
|
### Base de test isolée de la base de dev (`apps/api`)
|
||||||
|
|
||||||
|
|
@ -158,6 +171,13 @@ Un garde-fou (`assertRunningAgainstTestDatabase()`) refuse d'exécuter
|
||||||
`resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la
|
`resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la
|
||||||
seule base qu'il doit rejeter est ta vraie base de dev.
|
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
|
## Déploiement
|
||||||
|
|
||||||
Une seule image Docker (`apps/api/Dockerfile`) sert à la fois l'API et le frontend
|
Une seule image Docker (`apps/api/Dockerfile`) sert à la fois l'API et le frontend
|
||||||
|
|
@ -180,10 +200,12 @@ synchronisation de la table `sources` depuis le registre d'adaptateurs de code
|
||||||
puis `node dist/server.js`. Les trois étapes sont sûres/idempotentes à
|
puis `node dist/server.js`. Les trois étapes sont sûres/idempotentes à
|
||||||
répéter à chaque redémarrage du conteneur.
|
répéter à chaque redémarrage du conteneur.
|
||||||
|
|
||||||
`docker-compose.yml` ne définit donc que deux services : `postgres` et `app` (un
|
Le duo `postgres`/`app` de `docker-compose.yml` n'expose donc qu'un seul port
|
||||||
seul port, `APP_PORT`, défaut `3000` — plus de `WEB_PORT`/`CORS_ORIGIN` à
|
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
|
coordonner entre deux origines, le frontend et l'API sont désormais servis
|
||||||
la même origine).
|
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
|
**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
|
production est reliée directement au dépôt Git et reconstruit elle-même
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,16 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# JWT_EXPIRES_IN=7d
|
# JWT_EXPIRES_IN=7d
|
||||||
# AUTH_COOKIE_NAME=session
|
# AUTH_COOKIE_NAME=session
|
||||||
# CORS_ORIGIN=http://localhost:5173
|
# 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.:
|
# Required, no default on purpose — generate your own, e.g.:
|
||||||
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||||
JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
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"],
|
"extension": ["ts"],
|
||||||
"spec": "test/**/*.test.ts",
|
"spec": "test/**/*.test.ts",
|
||||||
"node-option": ["import=tsx"],
|
"node-option": ["import=tsx"],
|
||||||
"timeout": 10000
|
"timeout": 10000,
|
||||||
|
"require": ["test-support/mocha-root-hooks.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"node-nlp": "4.27.0",
|
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// list regardless of that real-world cardinality.
|
||||||
administeredHouses House[] @relation("HouseAdmin")
|
administeredHouses House[] @relation("HouseAdmin")
|
||||||
preferences UserPreference?
|
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")
|
@@map("user_profiles")
|
||||||
}
|
}
|
||||||
|
|
@ -511,12 +515,15 @@ model Ingredient {
|
||||||
/// catalog's own search with this ingredient's name).
|
/// catalog's own search with this ingredient's name).
|
||||||
reproducible Boolean @default(false)
|
reproducible Boolean @default(false)
|
||||||
|
|
||||||
recipes RecipeIngredient[]
|
recipes RecipeIngredient[]
|
||||||
allergies IngredientAllergy[]
|
allergies IngredientAllergy[]
|
||||||
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
|
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
|
||||||
dislikedBy UserProfileDislikedIngredient[]
|
dislikedBy UserProfileDislikedIngredient[]
|
||||||
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
|
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
|
||||||
diets IngredientDiet[]
|
diets IngredientDiet[]
|
||||||
|
/// Mentions of this ingredient detected in a step's free text alongside a
|
||||||
|
/// technique — see `StepTechStepIngredient`.
|
||||||
|
stepTechSteps StepTechStepIngredient[]
|
||||||
|
|
||||||
@@map("ingredients")
|
@@map("ingredients")
|
||||||
}
|
}
|
||||||
|
|
@ -592,7 +599,13 @@ model Unit {
|
||||||
type UnitType
|
type UnitType
|
||||||
toBaseFactor Decimal @default(1) @map("to_base_factor") @db.Decimal(12, 4)
|
toBaseFactor Decimal @default(1) @map("to_base_factor") @db.Decimal(12, 4)
|
||||||
|
|
||||||
recipeIngredients RecipeIngredient[]
|
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")
|
@@map("unit")
|
||||||
}
|
}
|
||||||
|
|
@ -623,22 +636,50 @@ model RecipeIngredient {
|
||||||
/// Matching a step's free text against these (`tech-step-matcher.ts`'s
|
/// Matching a step's free text against these (`tech-step-matcher.ts`'s
|
||||||
/// `TechStepClassifierService`) used to go through a DB-backed
|
/// `TechStepClassifierService`) used to go through a DB-backed
|
||||||
/// `TechStepMapping` table of per-locale regex expressions — replaced with
|
/// `TechStepMapping` table of per-locale regex expressions — replaced with
|
||||||
/// a node-nlp model trained from in-code data
|
/// a spaCy-based model (`services/tech-step-intent-service`) trained from
|
||||||
/// (`tech-step-training-data.ts`) once regexes turned out unable to
|
/// in-code data (`tech-step-training-data.ts`) once regexes turned out
|
||||||
/// generalize past their own literal vocabulary. Nothing queries/edits
|
/// unable to generalize past their own literal vocabulary. Nothing
|
||||||
/// that matching data at runtime anymore (it only ever feeds the
|
/// queries/edits that matching data at runtime anymore (it only ever feeds
|
||||||
/// classifier's one-time training pass), so it no longer needs a table of
|
/// that service's one-time training pass), so it no longer needs a table
|
||||||
/// its own — this row now only exists to be a stable id/key other tables
|
/// of its own — this row now only exists to be a stable id/key other
|
||||||
/// (`StepTechStep`) reference.
|
/// tables (`StepTechStep`) reference.
|
||||||
model TechStep {
|
model TechStep {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String @unique
|
key String @unique
|
||||||
|
|
||||||
steps StepTechStep[]
|
steps StepTechStep[]
|
||||||
|
/// Corrections where this technique was the *previous* (possibly wrong)
|
||||||
|
/// match — see `StepTechStepCorrection.previousTechStepId`.
|
||||||
|
correctionsAsPrevious StepTechStepCorrection[] @relation("PreviousTechStep")
|
||||||
|
/// Corrections where this technique was the *corrected* (user-asserted)
|
||||||
|
/// match — see `StepTechStepCorrection.correctedTechStepId`.
|
||||||
|
correctionsAsCorrected StepTechStepCorrection[] @relation("CorrectedTechStep")
|
||||||
|
/// Training-corpus suggestions targeting this technique — see
|
||||||
|
/// `TechStepTrainingSuggestion`.
|
||||||
|
trainingSuggestions TechStepTrainingSuggestion[]
|
||||||
|
|
||||||
@@map("tech_step")
|
@@map("tech_step")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `key` is `@unique`, same bare id+key shape as `TechStep` — no
|
||||||
|
/// categorization taxonomy like `Ingredient` needed yet, and no matching
|
||||||
|
/// data of its own here either: unlike `TechStep` (whose matching synonyms
|
||||||
|
/// used to live in TS and were moved into
|
||||||
|
/// `services/tech-step-intent-service`'s `training_data.py`), this catalog
|
||||||
|
/// was *born* owned by that service (`utensil_vocabulary.py`) since nothing
|
||||||
|
/// pre-existing needed it — this row only exists to be a stable id/key
|
||||||
|
/// `StepTechStepUtensil` references, and to carry a French label
|
||||||
|
/// (`apps/web`'s `catalog.utensils.<key>`, see `reference-seed-data.ts`'s
|
||||||
|
/// `UTENSILS`).
|
||||||
|
model Utensil {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
key String @unique
|
||||||
|
|
||||||
|
steps StepTechStepUtensil[]
|
||||||
|
|
||||||
|
@@map("utensil")
|
||||||
|
}
|
||||||
|
|
||||||
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the
|
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the
|
||||||
/// many-to-many noted in the spec doc: `order` only makes sense scoped to a
|
/// many-to-many noted in the spec doc: `order` only makes sense scoped to a
|
||||||
/// single recipe, which isn't reconcilable with steps being shared across
|
/// single recipe, which isn't reconcilable with steps being shared across
|
||||||
|
|
@ -650,8 +691,11 @@ model Step {
|
||||||
picture String?
|
picture String?
|
||||||
order Int
|
order Int
|
||||||
|
|
||||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
techSteps StepTechStep[]
|
techSteps StepTechStep[]
|
||||||
|
/// User-submitted corrections to this step's detected techniques — see
|
||||||
|
/// `StepTechStepCorrection`.
|
||||||
|
corrections StepTechStepCorrection[]
|
||||||
|
|
||||||
@@map("step")
|
@@map("step")
|
||||||
}
|
}
|
||||||
|
|
@ -682,18 +726,174 @@ model Step {
|
||||||
/// techniques from scratch (`recipe.service.ts`'s `updateRecipe` deletes
|
/// techniques from scratch (`recipe.service.ts`'s `updateRecipe` deletes
|
||||||
/// and recreates every `Step`/`StepTechStep`, never a partial patch) —
|
/// and recreates every `Step`/`StepTechStep`, never a partial patch) —
|
||||||
/// graceful degradation, not a permanent gap.
|
/// 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 {
|
model StepTechStep {
|
||||||
stepId Int @map("step_id")
|
stepId Int @map("step_id")
|
||||||
techStepId Int @map("tech_step_id")
|
techStepId Int @map("tech_step_id")
|
||||||
order Int
|
order Int
|
||||||
start Int?
|
start Int?
|
||||||
end Int?
|
end Int?
|
||||||
contextStart Int? @map("context_start")
|
contextStart Int? @map("context_start")
|
||||||
contextEnd Int? @map("context_end")
|
contextEnd Int? @map("context_end")
|
||||||
|
source String @default("auto")
|
||||||
|
|
||||||
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
|
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
|
||||||
techStep TechStep @relation(fields: [techStepId], 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])
|
@@id([stepId, order])
|
||||||
@@map("step_tech_step")
|
@@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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@ import { errorLogger } from "./middlewares/error-logger.js";
|
||||||
import { requestLogger } from "./middlewares/request-logger.js";
|
import { requestLogger } from "./middlewares/request-logger.js";
|
||||||
import { authRouter } from "./modules/auth/auth.routes.js";
|
import { authRouter } from "./modules/auth/auth.routes.js";
|
||||||
import { houseRouter } from "./modules/house/house.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 { planningRouter } from "./modules/planning/planning.routes.js";
|
||||||
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
||||||
import { profileRouter } from "./modules/profile/profile.routes.js";
|
import { profileRouter } from "./modules/profile/profile.routes.js";
|
||||||
import { recipeRouter } from "./modules/recipe/recipe.routes.js";
|
import { recipeRouter } from "./modules/recipe/recipe.routes.js";
|
||||||
import { referenceRouter } from "./modules/reference/reference.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";
|
import { sourcesRouter } from "./modules/sources/sources.routes.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -38,11 +40,18 @@ export function createServer(): ExpressServer {
|
||||||
|
|
||||||
server.mountRouter("/auth", authRouter);
|
server.mountRouter("/auth", authRouter);
|
||||||
server.mountRouter("/house", houseRouter);
|
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("/planning", planningRouter);
|
||||||
server.mountRouter("/preferences", preferencesRouter);
|
server.mountRouter("/preferences", preferencesRouter);
|
||||||
server.mountRouter("/profile", profileRouter);
|
server.mountRouter("/profile", profileRouter);
|
||||||
server.mountRouter("/recipes", recipeRouter);
|
server.mountRouter("/recipes", recipeRouter);
|
||||||
server.mountRouter("/reference", referenceRouter);
|
server.mountRouter("/reference", referenceRouter);
|
||||||
|
server.mountRouter("/shopping-list", shoppingListRouter);
|
||||||
server.mountRouter("/sources", sourcesRouter);
|
server.mountRouter("/sources", sourcesRouter);
|
||||||
|
|
||||||
// Serves the built frontend (production Docker image only — see
|
// Serves the built frontend (production Docker image only — see
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,36 @@ const envSchema = z.object({
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((value) => (value === undefined || value === "" ? undefined : value === "true")),
|
.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. */
|
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
||||||
|
|
|
||||||
|
|
@ -62,11 +62,12 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }>
|
||||||
//
|
//
|
||||||
// Just a flat list of stable ids here — the actual matching data (per-
|
// Just a flat list of stable ids here — the actual matching data (per-
|
||||||
// locale synonym lists + example phrasings the classifier trains on) lives
|
// locale synonym lists + example phrasings the classifier trains on) lives
|
||||||
// in `lib/recipe-matching/tech-step-training-data.ts`'s
|
// in `services/tech-step-intent-service/intent_service/training_data.py`'s
|
||||||
// `TECH_STEP_TRAINING_DATA`, not here: unlike this list, it's read by
|
// `TECH_STEP_TRAINING_DATA`, not here: it's owned and trained entirely by
|
||||||
// `TechStepClassifierService`'s training pass, not the seed script, so it
|
// that separate Python service (see its own README), not read by this
|
||||||
// doesn't belong alongside the rest of this file's DB-seeded reference
|
// seed script at all, so it doesn't belong alongside the rest of this
|
||||||
// data. Every entry here must have a matching entry there.
|
// file's DB-seeded reference data. Every entry here must have a matching
|
||||||
|
// entry there.
|
||||||
export const TECH_STEPS: string[] = [
|
export const TECH_STEPS: string[] = [
|
||||||
"cook",
|
"cook",
|
||||||
"fry",
|
"fry",
|
||||||
|
|
@ -94,6 +95,100 @@ export const TECH_STEPS: string[] = [
|
||||||
"bake",
|
"bake",
|
||||||
"plate",
|
"plate",
|
||||||
"coat",
|
"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
|
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
|
||||||
|
|
@ -1199,6 +1294,12 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
||||||
await prisma.techStep.upsert({ where: { key }, update: {}, create: { key } });
|
await prisma.techStep.upsert({ where: { key }, update: {}, create: { key } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Utensil: same idempotent bare id/key upsert as TechStep right above —
|
||||||
|
// no matching data alongside it either (see `UTENSILS`' own comment).
|
||||||
|
for (const key of UTENSILS) {
|
||||||
|
await prisma.utensil.upsert({ where: { key }, update: {}, create: { key } });
|
||||||
|
}
|
||||||
|
|
||||||
// `Allergy` itself carries no `key` — it's the selectable instance of a
|
// `Allergy` itself carries no `key` — it's the selectable instance of a
|
||||||
// keyed `Category` (see schema.prisma) — so seeding an allergen means one
|
// keyed `Category` (see schema.prisma) — so seeding an allergen means one
|
||||||
// Category (upserted by key) plus exactly one Allergy row under it,
|
// Category (upserted by key) plus exactly one Allergy row under it,
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,26 @@
|
||||||
import {
|
import {
|
||||||
INGREDIENT_LABEL_SYNONYMS_EN,
|
INGREDIENT_LABEL_SYNONYMS_EN,
|
||||||
|
INGREDIENT_LABEL_SYNONYMS_FR,
|
||||||
INGREDIENT_LABELS_EN,
|
INGREDIENT_LABELS_EN,
|
||||||
|
INGREDIENT_LABELS_FR,
|
||||||
UNIT_LABELS_EN,
|
UNIT_LABELS_EN,
|
||||||
|
UNIT_LABELS_FR,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import { normalizeText } from "./tech-step-matcher.js";
|
import { normalizeText } from "./tech-step-matcher.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the free-text `name`/`unit`/`quantity` a `RecipeSourceAdapter`
|
* Resolves the free-text `name`/`unit`/`quantity` a `RecipeSourceAdapter`
|
||||||
* lifts from an English-language source (`ParsedRecipeIngredient`) against
|
* lifts from a recipe source (`ParsedRecipeIngredient`) against our own
|
||||||
* our own `Ingredient`/`Unit` reference catalogs — the ingredient-side
|
* `Ingredient`/`Unit` reference catalogs — the ingredient-side counterpart
|
||||||
* counterpart to `tech-step-matcher.ts`'s technique detection, built for
|
* to `tech-step-matcher.ts`'s technique detection, built for the same
|
||||||
* the same reason: an English source's raw text has no idea our catalogs
|
* reason: a source's raw text has no idea our catalogs even exist.
|
||||||
* even exist.
|
|
||||||
*
|
*
|
||||||
* Unlike tech steps (regex mappings hand-authored per technique),
|
* Unlike tech steps (regex mappings hand-authored per technique),
|
||||||
* ingredient/unit labels are plain hand-written English text
|
* ingredient/unit labels are plain hand-written text, one table per
|
||||||
* (`INGREDIENT_LABELS_EN`/`UNIT_LABELS_EN`, `packages/shared`) — matching
|
* supported `locale` (`INGREDIENT_LABELS_EN`/`INGREDIENT_LABELS_FR`/
|
||||||
* them against arbitrary free text (extra adjectives, plurals, "large diced
|
* `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,
|
* yellow onion" for a catalog entry that's just "Onion") needs its own,
|
||||||
* lighter algorithm: word-tokenize both sides, naively stem for plurals,
|
* lighter algorithm: word-tokenize both sides, naively stem for plurals,
|
||||||
* then look for the catalog phrase's tokens as a contiguous run inside the
|
* then look for the catalog phrase's tokens as a contiguous run inside the
|
||||||
|
|
@ -36,17 +39,17 @@ import { normalizeText } from "./tech-step-matcher.js";
|
||||||
* one-time training pass.
|
* one-time training pass.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its English matching label. */
|
/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its matching label in whichever locale it was loaded for. */
|
||||||
export interface IngredientMatchEntry {
|
export interface IngredientMatchEntry {
|
||||||
ingredientId: number;
|
ingredientId: number;
|
||||||
/** English label from `INGREDIENT_LABELS_EN`, e.g. `"Chicken breast"` — matched against free text, never displayed. */
|
/** 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;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One `Unit` row trimmed to what {@link matchUnit} needs, alongside its accepted English spellings. */
|
/** One `Unit` row trimmed to what {@link matchUnit} needs, alongside its accepted spellings in whichever locale it was loaded for. */
|
||||||
export interface UnitMatchEntry {
|
export interface UnitMatchEntry {
|
||||||
unitId: number;
|
unitId: number;
|
||||||
/** Accepted spellings from `UNIT_LABELS_EN`, e.g. `["tbsp", "tbs", "tablespoon", "tablespoons"]`. */
|
/** 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[];
|
synonyms: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,19 +61,45 @@ export interface UnitMatchEntry {
|
||||||
* of every comparison go through it, not linguistically correct on its
|
* of every comparison go through it, not linguistically correct on its
|
||||||
* own — see the module doc comment.
|
* own — see the module doc comment.
|
||||||
*/
|
*/
|
||||||
function stemWord(word: string): string {
|
function stemWordEn(word: string): string {
|
||||||
if (word.endsWith("ies") && word.length > 4) return `${word.slice(0, -3)}y`;
|
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("es") && word.length > 3) return word.slice(0, -2);
|
||||||
if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1);
|
if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1);
|
||||||
return word;
|
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[] {
|
* 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)
|
return normalizeText(text)
|
||||||
.split(/[^a-z]+/)
|
.split(/[^a-z]+/)
|
||||||
.filter((word) => word.length > 0)
|
.filter((word) => word.length > 0)
|
||||||
.map(stemWord);
|
.map((word) => stemWord(word, locale));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether `needle` appears as a contiguous run inside `haystack`, at any starting position. */
|
/** Whether `needle` appears as a contiguous run inside `haystack`, at any starting position. */
|
||||||
|
|
@ -82,22 +111,193 @@ function containsSubsequence(haystack: string[], needle: string[]): boolean {
|
||||||
return false;
|
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
|
* Resolves free-text `name` (a `ParsedRecipeIngredient.name`, e.g. `"large
|
||||||
* diced yellow onions"`) to the best-matching `Ingredient` in `catalog`, or
|
* diced yellow onions"`) to the best-matching `Ingredient` in `catalog`, or
|
||||||
* `null` if nothing matches. Among every catalog entry whose label's words
|
* `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
|
* all appear as a contiguous run in `name`, **in the same order**, the one
|
||||||
* wins (most specific — "chicken breast" over bare "chicken"); ties break
|
* with the most words wins (most specific — "chicken breast" over bare
|
||||||
* on the lowest `ingredientId`, for a deterministic result independent of
|
* "chicken"); ties break on the lowest `ingredientId`, for a deterministic
|
||||||
* catalog order.
|
* 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[]): number | null {
|
export function matchIngredientName(
|
||||||
const nameTokens = tokenize(name);
|
name: string,
|
||||||
|
catalog: IngredientMatchEntry[],
|
||||||
|
locale = "en",
|
||||||
|
): number | null {
|
||||||
|
const nameTokens = tokenize(name, locale);
|
||||||
if (nameTokens.length === 0) return null;
|
if (nameTokens.length === 0) return null;
|
||||||
|
|
||||||
let best: { ingredientId: number; tokenCount: number } | null = null;
|
let best: { ingredientId: number; tokenCount: number } | null = null;
|
||||||
for (const entry of catalog) {
|
for (const entry of catalog) {
|
||||||
const labelTokens = tokenize(entry.label);
|
const labelTokens = tokenize(entry.label, locale);
|
||||||
if (!containsSubsequence(nameTokens, labelTokens)) continue;
|
if (!containsSubsequence(nameTokens, labelTokens)) continue;
|
||||||
if (
|
if (
|
||||||
best === null ||
|
best === null ||
|
||||||
|
|
@ -114,24 +314,44 @@ export function matchIngredientName(name: string, catalog: IngredientMatchEntry[
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves free-text `unitText` (e.g. `"tbsp"`, `"Cups"`) to the matching
|
* Resolves free-text `unitText` (e.g. `"tbsp"`, `"Cups"`, `"cuillères à
|
||||||
* `Unit` in `catalog`, or `null` if nothing matches. A unit is a single
|
* soupe de farine"`) to the best-matching `Unit` in `catalog`, or `null` if
|
||||||
* word by convention (see `UNIT_LABELS_EN`), so this is a whole-token
|
* nothing matches. Same ordered-contiguous-run search as
|
||||||
* equality check (after stemming/normalizing), not the substring search
|
* {@link matchIngredientName}, longest match wins — **not** the
|
||||||
* `matchIngredientName` does — `"cup"` shouldn't match inside an unrelated
|
* single-first-word equality check this function used before French
|
||||||
* longer word.
|
* 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[]): number | null {
|
export function matchUnit(
|
||||||
const tokens = tokenize(unitText);
|
unitText: string,
|
||||||
if (tokens.length === 0) return null;
|
catalog: UnitMatchEntry[],
|
||||||
const firstToken = tokens[0];
|
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 entry of catalog) {
|
||||||
if (entry.synonyms.some((synonym) => stemWord(normalizeText(synonym)) === firstToken)) {
|
for (const synonym of entry.synonyms) {
|
||||||
return entry.unitId;
|
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 null;
|
return best?.unitId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What {@link extractQuantity} pulls out of a leading numeric expression, alongside what's left of the string after it. */
|
/** What {@link extractQuantity} pulls out of a leading numeric expression, alongside what's left of the string after it. */
|
||||||
|
|
@ -143,7 +363,10 @@ export interface ExtractedQuantity {
|
||||||
|
|
||||||
// Leading "1 1/2", "1/2", "1.5", "1,5" or "2" (optionally followed by a
|
// 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 —
|
// 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.
|
// 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+)?)/;
|
const LEADING_QUANTITY_PATTERN = /^(\d+)\s+(\d+)\/(\d+)|^(\d+)\/(\d+)|^(\d+(?:[.,]\d+)?)/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -176,18 +399,42 @@ export function extractQuantity(rawText: string): ExtractedQuantity {
|
||||||
return { quantity, remainder: trimmed.slice(match[0].length).trim() };
|
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`), plus one extra entry per alternate wording (`INGREDIENT_LABEL_SYNONYMS_EN`, e.g. "Vanilla pod" alongside "Vanilla bean" — see issue #54) sharing the same `ingredientId`; `matchIngredientName` doesn't need to know synonyms exist, it just sees more candidate labels for the same ingredient. An ingredient with no English label yet is silently skipped, never a matching target. Meant to be fetched once per request and reused across every ingredient line, not re-queried per line. */
|
/**
|
||||||
export async function loadIngredientCatalog(): Promise<IngredientMatchEntry[]> {
|
* 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 {
|
try {
|
||||||
|
const labels = INGREDIENT_LABELS_BY_LOCALE[locale] ?? {};
|
||||||
|
const synonyms = INGREDIENT_LABEL_SYNONYMS_BY_LOCALE[locale] ?? {};
|
||||||
const ingredients = await prisma.ingredient.findMany({
|
const ingredients = await prisma.ingredient.findMany({
|
||||||
select: { id: true, key: true },
|
select: { id: true, key: true },
|
||||||
});
|
});
|
||||||
const catalog: IngredientMatchEntry[] = [];
|
const catalog: IngredientMatchEntry[] = [];
|
||||||
for (const ingredient of ingredients) {
|
for (const ingredient of ingredients) {
|
||||||
const label = INGREDIENT_LABELS_EN[ingredient.key];
|
const label = labels[ingredient.key];
|
||||||
if (label === undefined) continue;
|
if (label === undefined) continue;
|
||||||
catalog.push({ ingredientId: ingredient.id, label });
|
catalog.push({ ingredientId: ingredient.id, label });
|
||||||
for (const synonym of INGREDIENT_LABEL_SYNONYMS_EN[ingredient.key] ?? []) {
|
for (const synonym of synonyms[ingredient.key] ?? []) {
|
||||||
catalog.push({ ingredientId: ingredient.id, label: synonym });
|
catalog.push({ ingredientId: ingredient.id, label: synonym });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -200,15 +447,16 @@ export async function loadIngredientCatalog(): Promise<IngredientMatchEntry[]> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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}. */
|
/** 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(): Promise<UnitMatchEntry[]> {
|
export async function loadUnitCatalog(locale = "en"): Promise<UnitMatchEntry[]> {
|
||||||
try {
|
try {
|
||||||
|
const labels = UNIT_LABELS_BY_LOCALE[locale] ?? {};
|
||||||
const units = await prisma.unit.findMany({
|
const units = await prisma.unit.findMany({
|
||||||
select: { id: true, key: true },
|
select: { id: true, key: true },
|
||||||
});
|
});
|
||||||
const catalog: UnitMatchEntry[] = [];
|
const catalog: UnitMatchEntry[] = [];
|
||||||
for (const unit of units) {
|
for (const unit of units) {
|
||||||
const synonyms = UNIT_LABELS_EN[unit.key];
|
const synonyms = labels[unit.key];
|
||||||
if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms });
|
if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms });
|
||||||
}
|
}
|
||||||
return catalog;
|
return catalog;
|
||||||
|
|
|
||||||
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();
|
||||||
|
|
@ -100,12 +100,19 @@ export async function translateRecipeSteps(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* English label {@link matchUnit} is fed when a quantity was found but no
|
* Locale-specific label {@link matchUnit} is fed when a quantity was found
|
||||||
* unit word was — see the `unitId` fallback below. `"piece"` (`UNIT_LABELS_EN`,
|
* but no unit word was — see the `unitId` fallback below. Each is the
|
||||||
* `packages/shared`) is the catalog's generic "counted, no further unit"
|
* catalog's generic "counted, no further unit" entry (`Unit.key` `"piece"`)
|
||||||
* entry (French "unité").
|
* 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 = "piece";
|
const FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE: Record<string, string> = {
|
||||||
|
en: "piece",
|
||||||
|
fr: "unité",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
|
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
|
||||||
|
|
@ -127,20 +134,28 @@ const FALLBACK_COUNT_UNIT_LABEL = "piece";
|
||||||
* button disabled with no indication why on almost any recipe with a
|
* button disabled with no indication why on almost any recipe with a
|
||||||
* whole-item ingredient. No fallback when `quantity` itself is `null`
|
* whole-item ingredient. No fallback when `quantity` itself is `null`
|
||||||
* (e.g. `"To taste"`) — there's nothing to count, so nothing to default.
|
* (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(
|
export function translateRecipeIngredients(
|
||||||
ingredients: ParsedRecipeIngredient[],
|
ingredients: ParsedRecipeIngredient[],
|
||||||
ingredientCatalog: IngredientMatchEntry[],
|
ingredientCatalog: IngredientMatchEntry[],
|
||||||
unitCatalog: UnitMatchEntry[],
|
unitCatalog: UnitMatchEntry[],
|
||||||
|
locale = "en",
|
||||||
): TranslatedRecipeIngredient[] {
|
): TranslatedRecipeIngredient[] {
|
||||||
|
const fallbackCountUnitLabel = FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE[locale] ?? "piece";
|
||||||
return ingredients.map((ingredient) => {
|
return ingredients.map((ingredient) => {
|
||||||
const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog);
|
const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog, locale);
|
||||||
const extracted = extractQuantity(ingredient.rawText);
|
const extracted = extractQuantity(ingredient.rawText);
|
||||||
const quantity = ingredient.quantity ?? extracted.quantity;
|
const quantity = ingredient.quantity ?? extracted.quantity;
|
||||||
const unitText = ingredient.unit ?? extracted.remainder;
|
const unitText = ingredient.unit ?? extracted.remainder;
|
||||||
const unitId =
|
const unitId =
|
||||||
matchUnit(unitText, unitCatalog) ??
|
matchUnit(unitText, unitCatalog, locale) ??
|
||||||
(quantity !== null ? matchUnit(FALLBACK_COUNT_UNIT_LABEL, unitCatalog) : null);
|
(quantity !== null ? matchUnit(fallbackCountUnitLabel, unitCatalog, locale) : null);
|
||||||
return { ...ingredient, quantity, ingredientId, unitId };
|
return { ...ingredient, quantity, ingredientId, unitId };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -273,13 +288,19 @@ export function mergeDuplicateIngredients(
|
||||||
* locale that doesn't match the actual text's language doesn't degrade
|
* locale that doesn't match the actual text's language doesn't degrade
|
||||||
* gracefully, it just gets things wrong.
|
* gracefully, it just gets things wrong.
|
||||||
*
|
*
|
||||||
* Ingredient/unit matching only has English data today
|
* Ingredient/unit matching has data for `"en"` and `"fr"` today
|
||||||
* (`INGREDIENT_LABELS_EN`/`UNIT_LABELS_EN`, `packages/shared`) — for any
|
* (`INGREDIENT_LABELS_EN`/`_FR`, `UNIT_LABELS_EN`/`_FR`, `packages/shared`)
|
||||||
* `locale` other than `"en"` this skips `loadIngredientCatalog`/
|
* — `loadIngredientCatalog(locale)`/`loadUnitCatalog(locale)` are always
|
||||||
* `loadUnitCatalog` entirely and leaves every ingredient's `ingredientId`/
|
* called, never specially skipped for a particular locale: a locale with no
|
||||||
* `unitId` at the neutral `null` `translateRecipeSteps` already stubs in,
|
* label table of its own (anything but `"en"`/`"fr"`) just gets back empty
|
||||||
* the same "no matching-language data" degradation tech-step matching
|
* catalogs from those two loaders, and `translateRecipeIngredients` over an
|
||||||
* already has for a locale with no mappings.
|
* 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(
|
export async function translateRecipe(
|
||||||
recipe: ParsedRecipe,
|
recipe: ParsedRecipe,
|
||||||
|
|
@ -288,15 +309,18 @@ export async function translateRecipe(
|
||||||
try {
|
try {
|
||||||
const translated = await translateRecipeSteps(recipe, locale);
|
const translated = await translateRecipeSteps(recipe, locale);
|
||||||
|
|
||||||
if (locale !== "en") return translated;
|
|
||||||
|
|
||||||
const [ingredientCatalog, unitCatalog] = await Promise.all([
|
const [ingredientCatalog, unitCatalog] = await Promise.all([
|
||||||
loadIngredientCatalog(),
|
loadIngredientCatalog(locale),
|
||||||
loadUnitCatalog(),
|
loadUnitCatalog(locale),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
...translated,
|
...translated,
|
||||||
ingredients: translateRecipeIngredients(recipe.ingredients, ingredientCatalog, unitCatalog),
|
ingredients: translateRecipeIngredients(
|
||||||
|
recipe.ingredients,
|
||||||
|
ingredientCatalog,
|
||||||
|
unitCatalog,
|
||||||
|
locale,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Rethrown as-is — the caller (`sources.service.ts`) already
|
// Rethrown as-is — the caller (`sources.service.ts`) already
|
||||||
|
|
|
||||||
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 };
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { NlpManager } from "node-nlp";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.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
|
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe
|
||||||
|
|
@ -15,25 +20,27 @@ import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js";
|
||||||
* generalize past its own vocabulary — a step describing melting butter as
|
* 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
|
* "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
|
* regex could anchor on, yet unmistakably *means* `melt`. Replaced with a
|
||||||
* small hybrid pipeline built on `node-nlp` ({@link TechStepClassifierService}):
|
* 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** (node-nlp enum entities, `synonyms` in `TECH_STEP_TRAINING_DATA`)
|
* 1. **NER** (the intent service's `PhraseMatcher`, built from its own
|
||||||
* finds every *candidate* technique mention in the whole
|
* `training_data.py`'s `synonyms`) finds every *candidate* technique
|
||||||
* description, each with its exact character span — mechanically the
|
* mention in the whole description, each with its exact character span —
|
||||||
* same job the old regexes did, just as flat synonym lists instead of
|
* mechanically the same job the old regexes did, just as flat synonym
|
||||||
* hand-written patterns (node-nlp's own stemmer/fuzzy matching already
|
* lists instead of hand-written patterns. This step alone is *not* the
|
||||||
* covers minor conjugation/typo variance the regexes had to enumerate
|
* final answer — see step 3.
|
||||||
* by hand). This step alone is *not* the final answer — see step 3.
|
|
||||||
* 2. The description is cut into clauses around those candidate spans
|
* 2. The description is cut into clauses around those candidate spans
|
||||||
* ({@link splitIntoClauses}) — a step naming two techniques ("Dans une
|
* ({@link splitIntoClauses}) — a step naming two techniques ("Dans une
|
||||||
* poêle chaude, faire chauffer une noix de beurre" is both `preheat`
|
* poêle chaude, faire chauffer une noix de beurre" is both `preheat`
|
||||||
* and `melt`) needs each judged on its own surrounding context, not the
|
* and `melt`) needs each judged on its own surrounding context, not the
|
||||||
* whole step lumped into one classification.
|
* whole step lumped into one classification.
|
||||||
* 3. **NLP intent classification** (node-nlp's `NlpManager`, trained on
|
* 3. **NLP intent classification** (the intent service's `textcat`, trained
|
||||||
* `TECH_STEP_TRAINING_DATA`'s `utterances`) then classifies each clause
|
* on its own `training_data.py`'s `utterances`) then classifies each
|
||||||
* on its own — this is what actually delivers "meaning, not keywords":
|
* clause on its own — this is what actually delivers "meaning, not
|
||||||
* the classifier was deliberately trained on paraphrases that never use
|
* keywords": the classifier was deliberately trained on paraphrases that
|
||||||
* the technique's own verb (e.g. "jusqu'à ce que le beurre ait
|
* 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
|
* disparu" for `melt`), so a clause reaching it gets labeled by what it
|
||||||
* was trained to recognize as *meaning* a technique, not by which
|
* was trained to recognize as *meaning* a technique, not by which
|
||||||
* literal word the NER step happened to anchor on. The NER-implied
|
* literal word the NER step happened to anchor on. The NER-implied
|
||||||
|
|
@ -49,12 +56,12 @@ import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js";
|
||||||
*
|
*
|
||||||
* `normalizeText` and {@link splitIntoClauses} are pure (no DB/model
|
* `normalizeText` and {@link splitIntoClauses} are pure (no DB/model
|
||||||
* access) so they stay unit-testable in isolation (see
|
* access) so they stay unit-testable in isolation (see
|
||||||
* `test/tech-step-matcher.test.ts`); the classifier itself needs a one-time
|
* `test/tech-step-matcher.test.ts`); this class only ever needs a
|
||||||
* training pass (`_ensureTrained`, node-nlp's `NlpManager.train()`) plus a
|
* `TechStep.key -> id` lookup from the DB, memoized on the shared
|
||||||
* `TechStep.key -> id` lookup from the DB, both memoized on the shared
|
* {@link techStepClassifier} singleton rather than repeated per call — the
|
||||||
* {@link techStepClassifier} singleton rather than repeated per call —
|
* NLP model itself trains once, inside `services/tech-step-intent-service`'s
|
||||||
* training is the expensive part (a few hundred ms for this corpus), never
|
* own startup, entirely independently of this class (see that service's
|
||||||
* worth redoing per request let alone per step.
|
* README — this repo no longer pushes any corpus to it over HTTP).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -89,6 +96,15 @@ export function normalizeText(text: string): string {
|
||||||
* Persisted as `StepTechStep.start`/`end`/`contextStart`/`contextEnd`
|
* Persisted as `StepTechStep.start`/`end`/`contextStart`/`contextEnd`
|
||||||
* (`recipe.service.ts`) so the recipe detail view can highlight both spans,
|
* (`recipe.service.ts`) so the recipe detail view can highlight both spans,
|
||||||
* not just know a technique was mentioned somewhere.
|
* 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 {
|
export interface TechStepMatch {
|
||||||
techStepId: number;
|
techStepId: number;
|
||||||
|
|
@ -96,6 +112,21 @@ export interface TechStepMatch {
|
||||||
end: number;
|
end: number;
|
||||||
contextStart: number;
|
contextStart: number;
|
||||||
contextEnd: 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. */
|
/** A candidate technique mention found by NER — the raw material {@link splitIntoClauses} cuts a description around. */
|
||||||
|
|
@ -241,77 +272,90 @@ export function splitIntoClauses(
|
||||||
* `TECH_STEP_TRAINING_DATA` — see `test/tech-step-matcher.test.ts` for the
|
* `TECH_STEP_TRAINING_DATA` — see `test/tech-step-matcher.test.ts` for the
|
||||||
* cases this threshold was picked to pass.
|
* cases this threshold was picked to pass.
|
||||||
*
|
*
|
||||||
* Raised from `0.65` after finding real (non-adversarial) misclassified
|
* Recalibrated for the migration off `node-nlp` to
|
||||||
* clauses that scored just above the old threshold — e.g. English recipe
|
* `services/tech-step-intent-service` (spaCy `textcat`, exclusive classes)
|
||||||
* text run through the French classifier (which must find *nothing*,
|
* — its score distribution is meaningfully different from node-nlp's own
|
||||||
* confirmed by `recipe-translation.test.ts`'s own locale-isolation test)
|
* classifier, and shifts again every time the corpus' technique count
|
||||||
* scored `0.69` for `boil`, essentially classifier noise on
|
* changes (more exclusive classes generally means a *lower* natural
|
||||||
* out-of-vocabulary input rather than a real, confident verdict. The
|
* confidence ceiling, softmax mass spread thinner).
|
||||||
* clauses this threshold exists to actually trust score far higher in
|
*
|
||||||
* practice (`0.91`–`1.0` for the real corrected cases found this session)
|
* Currently `0.25`, set against the corpus as expanded to ~74 techniques
|
||||||
* — `0.75` sits comfortably above the noise floor and below every genuine
|
* (`services/tech-step-intent-service/intent_service/training_data.py`,
|
||||||
* match seen so far.
|
* `_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.
|
||||||
*/
|
*/
|
||||||
const CONFIDENCE_THRESHOLD = 0.75;
|
export const CONFIDENCE_THRESHOLD = 0.25;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trains and owns the `node-nlp` model behind {@link matchTechStepSpans} —
|
* 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
|
* 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
|
* service-style-logic convention, even though it's only ever used as the
|
||||||
* one shared {@link techStepClassifier} singleton below: it holds real
|
* one shared {@link techStepClassifier} singleton below: it holds real
|
||||||
* state (the trained model, the memoized training/lookup promises), not
|
* state (the memoized lookup promise), not just grouped stateless helpers.
|
||||||
* 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 {
|
export class TechStepClassifierService {
|
||||||
/** node-nlp's manager — both NER (enum entities) and NLP (intent classification) live on the same instance, trained together. */
|
/** 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 readonly _manager: NlpManager;
|
private _techStepIdsLoaded: Promise<void> | undefined;
|
||||||
/** Memoized training pass — `undefined` until the first call starts it, after which every caller (concurrent or not) awaits the same promise rather than retraining. */
|
|
||||||
private _trained: Promise<void> | undefined;
|
|
||||||
/** Memoized `TechStep.key -> id` lookup — training data only knows techniques by their stable `uid`/`key`, resolved to the real DB id once, alongside training. */
|
|
||||||
private _techStepIdByUid: Map<string, number> | undefined;
|
private _techStepIdByUid: Map<string, number> | undefined;
|
||||||
|
|
||||||
public constructor() {
|
/** 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`. */
|
||||||
this._manager = new NlpManager({
|
private _utensilIdsLoaded: Promise<void> | undefined;
|
||||||
languages: ["fr", "en"],
|
private _utensilIdByUid: Map<string, number> | undefined;
|
||||||
forceNER: true,
|
|
||||||
nlu: { log: false },
|
|
||||||
// node-nlp's enum-entity NER defaults to a fuzzy (Levenshtein-based)
|
|
||||||
// 0.8 accuracy threshold — loose enough that e.g. "faire" (the
|
|
||||||
// generic French helper verb in almost every recipe step) fuzzy-
|
|
||||||
// matches `fry`'s synonym "frire" at 0.80, a false positive found
|
|
||||||
// while tuning this against the real training corpus. `1` (exact,
|
|
||||||
// after node-nlp's own case/accent/stemming normalization — real
|
|
||||||
// conjugation variance is still covered by listing each form in
|
|
||||||
// `tech-step-training-data.ts`) removed it without losing any real
|
|
||||||
// match. Precision matters more than recall for this stage — NER
|
|
||||||
// only proposes candidate split points, `_classifyClause`'s trained
|
|
||||||
// model (not fuzzy string distance) is what actually has to be
|
|
||||||
// right.
|
|
||||||
ner: { threshold: 1 },
|
|
||||||
// node-nlp defaults to `autoSave`/`autoLoad: true` — silently
|
|
||||||
// persisting the trained model to a `model.nlp` file in the process's
|
|
||||||
// cwd, and *loading from that file instead of retraining* the next
|
|
||||||
// time a manager is constructed, if the file already exists. Found
|
|
||||||
// this the hard way: a stray `model.nlp` appeared at the repo root
|
|
||||||
// after running this locally. That's the opposite of what this
|
|
||||||
// service wants — `TECH_STEP_TRAINING_DATA` in code is the single
|
|
||||||
// source of truth this always trains fresh from (see this file's own
|
|
||||||
// doc comment) — a stale on-disk model silently shadowing a
|
|
||||||
// corpus/threshold update would be a nasty, hard-to-notice class of
|
|
||||||
// bug. Both off; nothing here should ever touch disk.
|
|
||||||
autoSave: false,
|
|
||||||
autoLoad: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forces training plus node-nlp's own one-time lazy setup (loading its
|
* Forces the `TechStep.key -> id` lookup to load now, synchronously with
|
||||||
* bundled per-language stemmers/tokenizers on the *first* real
|
* server startup (see `server.ts`, which also retries this against a
|
||||||
* `NlpManager.process()` call takes a few seconds by itself, separate
|
* not-yet-reachable intent service), rather than stalling whichever
|
||||||
* from and much slower than the ~40ms `train()` pass — measured against
|
* request happens to be first to save/preview a recipe. Doesn't wait on
|
||||||
* this corpus while tuning the pipeline) to happen now, synchronously
|
* `services/tech-step-intent-service` finishing its own training — that
|
||||||
* with server startup (see `server.ts`), rather than stalling whichever
|
* service is only ever considered "up" by Docker Compose/CI once it
|
||||||
* request happens to be first to save/preview a recipe.
|
* 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> {
|
public async warmUp(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -335,26 +379,35 @@ export class TechStepClassifierService {
|
||||||
*/
|
*/
|
||||||
public async matchTechStepSpans(description: string, locale: string): Promise<TechStepMatch[]> {
|
public async matchTechStepSpans(description: string, locale: string): Promise<TechStepMatch[]> {
|
||||||
try {
|
try {
|
||||||
await this._ensureTrained();
|
await Promise.all([this._ensureTechStepIdsLoaded(), this._ensureUtensilIdsLoaded()]);
|
||||||
if (description.trim().length === 0) return [];
|
if (description.trim().length === 0) return [];
|
||||||
|
|
||||||
const nerResult = await this._manager.process(locale, description);
|
// 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
|
const candidates: TechniqueCandidate[] = nerResult.entities
|
||||||
// node-nlp's language plugins also auto-extract their own built-in
|
.filter((entity) => entity.kind === "technique")
|
||||||
// entities (numbers, durations, dates…) alongside the enum
|
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
|
||||||
// entities `_train` registered from `TECH_STEP_TRAINING_DATA` —
|
const utensilEntities = nerResult.entities.filter((entity) => entity.kind === "utensil");
|
||||||
// `type === "enum"` is what tells the two apart; without this
|
|
||||||
// filter a step like "10 minutes" would hand `splitIntoClauses` a
|
|
||||||
// bogus "duration" candidate that resolves to no real technique.
|
|
||||||
.filter((entity) => entity.type === "enum")
|
|
||||||
.map((entity) => ({
|
|
||||||
uid: entity.entity,
|
|
||||||
start: entity.start,
|
|
||||||
// node-nlp's own `end` is inclusive (verified against a real
|
|
||||||
// trained model) — `+ 1` converts to this module's `[start, end)`
|
|
||||||
// convention, matching `String.prototype.slice`.
|
|
||||||
end: entity.end + 1,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const clauses = splitIntoClauses(description, candidates);
|
const clauses = splitIntoClauses(description, candidates);
|
||||||
const matches: TechStepMatch[] = [];
|
const matches: TechStepMatch[] = [];
|
||||||
|
|
@ -368,12 +421,35 @@ export class TechStepClassifierService {
|
||||||
// persist a dangling id.
|
// persist a dangling id.
|
||||||
if (techStepId === undefined) continue;
|
if (techStepId === undefined) continue;
|
||||||
const span = clause.anchor ?? { start: clause.start, end: clause.end };
|
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({
|
matches.push({
|
||||||
techStepId,
|
techStepId,
|
||||||
start: span.start,
|
start: span.start,
|
||||||
end: span.end,
|
end: span.end,
|
||||||
contextStart: clause.start,
|
contextStart: clause.start,
|
||||||
contextEnd: clause.end,
|
contextEnd: clause.end,
|
||||||
|
ingredients,
|
||||||
|
utensils,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -388,6 +464,61 @@ export class TechStepClassifierService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits `description` into clauses exactly like {@link matchTechStepSpans}
|
||||||
|
* does, but returns each clause's *raw* classification detail
|
||||||
|
* ({@link TechStepClauseClassification}) instead of the threshold-applied,
|
||||||
|
* anchor-fallback-resolved `TechStepMatch` — see that type's doc comment
|
||||||
|
* for why/who needs this. Deliberately a separate traversal rather than a
|
||||||
|
* shared refactor with `matchTechStepSpans`/`_classifyClause`: this method
|
||||||
|
* exists purely to add a new, additive read path without risking a
|
||||||
|
* behavior change to the two already-relied-on methods above.
|
||||||
|
*/
|
||||||
|
public async classifyClauses(
|
||||||
|
description: string,
|
||||||
|
locale: string,
|
||||||
|
): Promise<TechStepClauseClassification[]> {
|
||||||
|
try {
|
||||||
|
await this._ensureTechStepIdsLoaded();
|
||||||
|
if (description.trim().length === 0) return [];
|
||||||
|
|
||||||
|
const nerResult = await intentServiceClient.process(locale, description);
|
||||||
|
const candidates: TechniqueCandidate[] = nerResult.entities
|
||||||
|
.filter((entity) => entity.kind === "technique")
|
||||||
|
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
|
||||||
|
|
||||||
|
const clauses = splitIntoClauses(description, candidates);
|
||||||
|
const results: TechStepClauseClassification[] = [];
|
||||||
|
for (const clause of clauses) {
|
||||||
|
const clauseText = description.slice(clause.start, clause.end).trim();
|
||||||
|
const anchorUid = clause.anchor?.uid ?? null;
|
||||||
|
if (clauseText.length === 0) {
|
||||||
|
results.push({
|
||||||
|
clauseText,
|
||||||
|
start: clause.start,
|
||||||
|
end: clause.end,
|
||||||
|
anchorUid,
|
||||||
|
intentUid: null,
|
||||||
|
score: 0,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const result = await intentServiceClient.process(locale, clauseText);
|
||||||
|
results.push({
|
||||||
|
clauseText,
|
||||||
|
start: clause.start,
|
||||||
|
end: clause.end,
|
||||||
|
anchorUid,
|
||||||
|
intentUid: result.intent,
|
||||||
|
score: result.intent === null ? 0 : result.score,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see matchTechStepSpans()'s catch comment above
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience wrapper around {@link matchTechStepSpans} for callers that
|
* Convenience wrapper around {@link matchTechStepSpans} for callers that
|
||||||
* only care about *which* techniques matched, not where — e.g.
|
* only care about *which* techniques matched, not where — e.g.
|
||||||
|
|
@ -421,8 +552,8 @@ export class TechStepClassifierService {
|
||||||
const clauseText = description.slice(clause.start, clause.end).trim();
|
const clauseText = description.slice(clause.start, clause.end).trim();
|
||||||
if (clauseText.length === 0) return clause.anchor?.uid ?? null;
|
if (clauseText.length === 0) return clause.anchor?.uid ?? null;
|
||||||
|
|
||||||
const result = await this._manager.process(locale, clauseText);
|
const result = await intentServiceClient.process(locale, clauseText);
|
||||||
if (result.intent !== "None" && result.score >= CONFIDENCE_THRESHOLD) {
|
if (result.intent !== null && result.score >= CONFIDENCE_THRESHOLD) {
|
||||||
return result.intent;
|
return result.intent;
|
||||||
}
|
}
|
||||||
return clause.anchor?.uid ?? null;
|
return clause.anchor?.uid ?? null;
|
||||||
|
|
@ -432,52 +563,56 @@ export class TechStepClassifierService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trains `_manager` from {@link TECH_STEP_TRAINING_DATA} and resolves the
|
* Resolves the `uid -> TechStep.id` lookup exactly once — memoized on
|
||||||
* `uid -> TechStep.id` lookup, both exactly once — memoized on
|
* `_techStepIdsLoaded` so a burst of concurrent calls (several steps of
|
||||||
* `_trained` so a burst of concurrent calls (several steps of the same
|
* the same recipe save, awaited via the same event loop tick) all await
|
||||||
* recipe save, awaited via the same event loop tick) all await the one
|
* the one in-flight DB query rather than each firing their own.
|
||||||
* in-flight training pass rather than each kicking off their own.
|
|
||||||
*/
|
*/
|
||||||
private async _ensureTrained(): Promise<void> {
|
private async _ensureTechStepIdsLoaded(): Promise<void> {
|
||||||
if (this._trained === undefined) {
|
if (this._techStepIdsLoaded === undefined) {
|
||||||
this._trained = this._train();
|
this._techStepIdsLoaded = this._loadTechStepIds();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this._trained;
|
await this._techStepIdsLoaded;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A failed training pass must be retried by the *next* call, not
|
// A failed load must be retried by the *next* call, not leave every
|
||||||
// leave every future call permanently rejecting against a stale
|
// future call permanently rejecting against a stale failed promise.
|
||||||
// failed promise.
|
this._techStepIdsLoaded = undefined;
|
||||||
this._trained = undefined;
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _train(): Promise<void> {
|
private async _loadTechStepIds(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
|
const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
|
||||||
this._techStepIdByUid = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
|
this._techStepIdByUid = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see matchTechStepSpans()'s catch comment above
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const entry of TECH_STEP_TRAINING_DATA) {
|
/** `Utensil.key -> id` counterpart of {@link _ensureTechStepIdsLoaded} — same memoize-once-retry-on-failure shape. */
|
||||||
for (const [locale, data] of [
|
private async _ensureUtensilIdsLoaded(): Promise<void> {
|
||||||
["fr", entry.fr],
|
if (this._utensilIdsLoaded === undefined) {
|
||||||
["en", entry.en],
|
this._utensilIdsLoaded = this._loadUtensilIds();
|
||||||
] as const) {
|
}
|
||||||
if (data.synonyms.length > 0) {
|
try {
|
||||||
this._manager.addNamedEntityText(entry.uid, entry.uid, [locale], data.synonyms);
|
await this._utensilIdsLoaded;
|
||||||
}
|
} catch (err) {
|
||||||
for (const utterance of data.utterances) {
|
this._utensilIdsLoaded = undefined;
|
||||||
this._manager.addDocument(locale, utterance, entry.uid);
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await this._manager.train();
|
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) {
|
} catch (err) {
|
||||||
throw err; // see matchTechStepSpans()'s catch comment above
|
throw err; // see matchTechStepSpans()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single shared instance — training is expensive enough (a few hundred ms) that every caller must reuse the one already-trained model, never spin up their own. */
|
/** 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();
|
export const techStepClassifier = new TechStepClassifierService();
|
||||||
|
|
|
||||||
|
|
@ -1,911 +0,0 @@
|
||||||
/**
|
|
||||||
* Training corpus for {@link TechStepClassifierService} (`tech-step-matcher.ts`)
|
|
||||||
* — one entry per `TechStep` (`uid` matches `reference-seed-data.ts`'s
|
|
||||||
* `TECH_STEPS`, which still owns the reference `TechStep` rows themselves;
|
|
||||||
* this file replaces `TECH_STEPS[].mappings`' regex expressions as the
|
|
||||||
* *matching* data source).
|
|
||||||
*
|
|
||||||
* Two distinct kinds of content per technique/locale, feeding two distinct
|
|
||||||
* mechanisms of the classifier (see that file's doc comment for why both
|
|
||||||
* are needed):
|
|
||||||
*
|
|
||||||
* - `synonyms` — short literal words/set phrases, fed to node-nlp's NER
|
|
||||||
* (enum entities). Mechanically equivalent to the old regexes' verb-form
|
|
||||||
* alternations, just spelled out as plain words instead of a pattern
|
|
||||||
* (node-nlp's own stemmer/fuzzy matching already covers minor
|
|
||||||
* conjugation/typo variance that the regexes had to enumerate by hand).
|
|
||||||
* Used only to find *candidate* technique mentions and cut a step into
|
|
||||||
* clauses around them — never the final answer on their own.
|
|
||||||
* - `utterances` — full example clauses, fed to node-nlp's NLP Manager as
|
|
||||||
* training documents for the intent classifier. Deliberately mixes
|
|
||||||
* keyword-anchored phrasings (reinforces the obvious case) with
|
|
||||||
* paraphrases that never use the technique's own verb at all (e.g.
|
|
||||||
* "jusqu'à ce que le beurre ait disparu" for `melt`) — this second kind
|
|
||||||
* is what actually delivers on "comprendre le sens, pas juste les mots
|
|
||||||
* clés" (see the PR this file was introduced in): a clause reaching the
|
|
||||||
* classifier gets labeled by what it's trained to recognize as *meaning*
|
|
||||||
* this technique, not by which literal word triggered its extraction.
|
|
||||||
*
|
|
||||||
* Kept as static in-code data (not DB rows, unlike the old
|
|
||||||
* `TechStepMapping` table) because nothing needs to query/edit it at
|
|
||||||
* runtime — it only ever feeds one thing, the classifier's one-time
|
|
||||||
* training pass (see `TechStepClassifierService._ensureTrained`) — same
|
|
||||||
* reasoning `INGREDIENT_LABELS_EN` (`packages/shared`) is a plain object,
|
|
||||||
* not a database table.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** One technique's matching data for one locale — see this file's doc comment for what each list feeds. */
|
|
||||||
export interface TechStepLocaleTrainingData {
|
|
||||||
synonyms: string[];
|
|
||||||
utterances: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One technique's full training entry — `uid` must match a `TECH_STEPS[].uid` in `reference-seed-data.ts`. */
|
|
||||||
export interface TechStepTrainingEntry {
|
|
||||||
uid: string;
|
|
||||||
fr: TechStepLocaleTrainingData;
|
|
||||||
en: TechStepLocaleTrainingData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
|
||||||
{
|
|
||||||
uid: "cook",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"cuire",
|
|
||||||
"cuisez",
|
|
||||||
"cuisant",
|
|
||||||
"cuisson",
|
|
||||||
"cuit",
|
|
||||||
"cuite",
|
|
||||||
"cuites",
|
|
||||||
"cuits",
|
|
||||||
"cuisiner",
|
|
||||||
"cuisinez",
|
|
||||||
"cuisiné",
|
|
||||||
"cuisinée",
|
|
||||||
"faire cuire",
|
|
||||||
"laisser cuire",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"faire cuire à feu moyen",
|
|
||||||
"laisser cuire jusqu'à ce que ce soit prêt",
|
|
||||||
"la cuisson dure environ dix minutes",
|
|
||||||
"jusqu'à ce que la viande ne soit plus rose au centre",
|
|
||||||
"poursuivre la cuisson à couvert",
|
|
||||||
// Two real recipe clauses found misclassified (as `preheat` and
|
|
||||||
// `panFry` respectively, both above the confidence threshold) once
|
|
||||||
// real, longer, comma-heavy sentences started reaching the
|
|
||||||
// classifier — neither error came from a missing keyword (both
|
|
||||||
// clauses' own NER anchor, "laisser cuire"/"faire cuire", was
|
|
||||||
// already right), just the classifier's low-heat/occasional-
|
|
||||||
// stirring phrasing not resembling anything short and clean-cut it
|
|
||||||
// had actually been trained on.
|
|
||||||
"baisser le feu et laisser cuire à découvert encore un quart d'heure",
|
|
||||||
"faire cuire à feu doux en remuant de temps en temps",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "cooked through"/"cooking through" — both are word-prefix
|
|
||||||
// extensions of "cooked"/"cooking" above, so any text containing them
|
|
||||||
// matches BOTH the short and long form as separate overlapping NER
|
|
||||||
// candidates, corrupting clause-splitting (confirmed via "It should
|
|
||||||
// be cooking through evenly", which spuriously grew a second,
|
|
||||||
// wrongly-classified `roast` candidate). See this pattern flagged
|
|
||||||
// throughout the file wherever it was found — the fix is always to
|
|
||||||
// drop the longer, redundant form rather than keep both.
|
|
||||||
synonyms: ["cook", "cooks", "cooked", "cooking"],
|
|
||||||
utterances: [
|
|
||||||
"cook over medium heat",
|
|
||||||
"cook until done",
|
|
||||||
"cooking takes about ten minutes",
|
|
||||||
"until no longer pink in the middle",
|
|
||||||
"continue cooking covered",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "fry",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"frire",
|
|
||||||
"frit",
|
|
||||||
"frite",
|
|
||||||
"frites",
|
|
||||||
"friture",
|
|
||||||
"faire frire",
|
|
||||||
"faites frire",
|
|
||||||
"bain de friture",
|
|
||||||
"huile de friture",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"faire frire dans l'huile chaude",
|
|
||||||
"plonger dans la friture",
|
|
||||||
"jusqu'à ce que ce soit doré et croustillant à l'extérieur",
|
|
||||||
"l'huile doit être bien chaude avant d'y plonger les morceaux",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "frying oil" — a word-prefix extension of "frying" above (see
|
|
||||||
// the `cook` entry's comment for why that duplicates/corrupts NER
|
|
||||||
// candidates; here it was even worse, misclassifying as `preheat`).
|
|
||||||
synonyms: ["fry", "fries", "fried", "frying", "deep fry", "deep-fried", "deep frying"],
|
|
||||||
utterances: [
|
|
||||||
"fry in hot oil",
|
|
||||||
"deep fry until golden",
|
|
||||||
"until crisp and golden on the outside",
|
|
||||||
"the oil should be very hot before adding the pieces",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "melt",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"fondre",
|
|
||||||
"fondu",
|
|
||||||
"fondue",
|
|
||||||
"fondues",
|
|
||||||
"faire fondre",
|
|
||||||
"faites fondre",
|
|
||||||
// Also a plausible way to say "melt" (heating something — usually
|
|
||||||
// a fat — until it liquefies), not just a `preheat` phrasing —
|
|
||||||
// restores what the regex-based system anchored on before this
|
|
||||||
// pipeline replaced it.
|
|
||||||
"faire chauffer",
|
|
||||||
"faites chauffer",
|
|
||||||
"liquéfier",
|
|
||||||
"liquéfiez",
|
|
||||||
"liquéfié",
|
|
||||||
"faire liquéfier",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"faire fondre le beurre",
|
|
||||||
"jusqu'à ce que le beurre ait disparu dans la poêle",
|
|
||||||
"le beurre doit être complètement liquide",
|
|
||||||
"laisser le fromage devenir tout liquide sur feu doux",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["melt", "melts", "melted", "melting", "liquefy", "liquefied"],
|
|
||||||
utterances: [
|
|
||||||
"melt the butter",
|
|
||||||
"until the butter has completely disappeared into the pan",
|
|
||||||
"the butter should be fully liquid",
|
|
||||||
"let the cheese turn completely liquid over low heat",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "deglaze",
|
|
||||||
fr: {
|
|
||||||
// NOT "déglacer la poêle"/"déglacer le fond de cuisson" — both are
|
|
||||||
// word-prefix extensions of "déglacer" above (see `cook`'s comment
|
|
||||||
// for why that duplicates NER candidates).
|
|
||||||
synonyms: ["déglacer", "déglacez", "déglacé", "déglacée", "déglaçage"],
|
|
||||||
utterances: [
|
|
||||||
"déglacer avec le vin blanc",
|
|
||||||
"verser le vin dans la poêle chaude pour décoller les sucs",
|
|
||||||
"gratter les sucs de cuisson au fond de la casserole avec un peu de bouillon",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "deglaze the pan" — a word-prefix extension of "deglaze" above
|
|
||||||
// (see `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["deglaze", "deglazes", "deglazed", "deglazing", "lift the browned bits"],
|
|
||||||
utterances: [
|
|
||||||
"deglaze with white wine",
|
|
||||||
"pour the wine into the hot pan to lift the browned bits",
|
|
||||||
"scrape up the browned bits at the bottom of the pan with a splash of stock",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "simmer",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"mijoter",
|
|
||||||
"mijotez",
|
|
||||||
"mijote",
|
|
||||||
"mijotant",
|
|
||||||
"mijoté",
|
|
||||||
"frémir",
|
|
||||||
"frémissant",
|
|
||||||
"frémissante",
|
|
||||||
"à petit feu",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"laisser mijoter à feu doux",
|
|
||||||
"faire mijoter pendant une heure",
|
|
||||||
"de petites bulles doivent remonter doucement à la surface",
|
|
||||||
"laisser cuire tout doucement à couvert pendant longtemps",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "simmering gently" — a word-prefix extension of "simmering"
|
|
||||||
// above (see `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["simmer", "simmers", "simmered", "simmering", "gentle simmer", "low simmer"],
|
|
||||||
utterances: [
|
|
||||||
"let it simmer over low heat",
|
|
||||||
"simmer for one hour",
|
|
||||||
"small bubbles should gently rise to the surface",
|
|
||||||
"let it cook very gently, covered, for a long time",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "boil",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"bouillir",
|
|
||||||
"bouillant",
|
|
||||||
"bouillie",
|
|
||||||
"bouillies",
|
|
||||||
"ébullition",
|
|
||||||
"porter à ébullition",
|
|
||||||
"gros bouillons",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"porter à ébullition",
|
|
||||||
"faire bouillir l'eau",
|
|
||||||
"de grosses bulles doivent agiter la surface avec force",
|
|
||||||
"jusqu'à ce que ça bouillonne franchement",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "boiling point" — a word-prefix extension of "boiling" above
|
|
||||||
// (see `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["boil", "boils", "boiled", "boiling", "rolling boil"],
|
|
||||||
utterances: [
|
|
||||||
"bring to a boil",
|
|
||||||
"boil the water",
|
|
||||||
"large bubbles should be vigorously breaking the surface",
|
|
||||||
"until it's rolling vigorously",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "roast",
|
|
||||||
fr: {
|
|
||||||
// NOT "rôti au four" — a word-prefix extension of "rôti" above (see
|
|
||||||
// `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["rôtir", "rôti", "rôtie", "rôties", "rôtis", "rôtissage"],
|
|
||||||
utterances: [
|
|
||||||
"faire rôtir la volaille entière",
|
|
||||||
"le rôti doit dorer uniformément de tous les côtés",
|
|
||||||
"cuire la pièce de viande entière au four à chaleur sèche",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["roast", "roasts", "roasted", "roasting", "oven-roast", "oven roasted"],
|
|
||||||
utterances: [
|
|
||||||
"roast the whole bird",
|
|
||||||
"it should brown evenly on every side",
|
|
||||||
"cook the whole piece of meat in dry oven heat",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "grill",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"griller",
|
|
||||||
"grillez",
|
|
||||||
"grillé",
|
|
||||||
"grillée",
|
|
||||||
"grillées",
|
|
||||||
"grillade",
|
|
||||||
"grillades",
|
|
||||||
"barbecue",
|
|
||||||
"au barbecue",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"faire griller sur la grille du barbecue",
|
|
||||||
"marquer les steaks sur une plaque brûlante",
|
|
||||||
"des traces de quadrillage doivent apparaître à la cuisson",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["grill", "grills", "grilled", "grilling", "barbecue", "char-grill", "charbroiled"],
|
|
||||||
utterances: [
|
|
||||||
"grill on the barbecue rack",
|
|
||||||
"sear the steaks on a scorching-hot plate",
|
|
||||||
"char marks should appear as it cooks",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "panFry",
|
|
||||||
fr: {
|
|
||||||
// Deliberately NOT "poêlé"/"poêlée"/"poêlés" here, despite reading
|
|
||||||
// like natural panFry vocabulary: node-nlp's French stemmer reduces
|
|
||||||
// them to the same root as the bare noun "poêle" (a pan), so
|
|
||||||
// registering them made every plain mention of "poêle" — e.g.
|
|
||||||
// `preheat`'s own "la poêle" — a false-positive panFry candidate too.
|
|
||||||
// Found via the "jusqu'à ce que le beurre ait disparu dans la poêle"
|
|
||||||
// regression test, which unexpectedly grew a spurious panFry match.
|
|
||||||
synonyms: ["sauter", "sautez", "sauté", "sautée", "sautées", "sautant", "à la poêle"],
|
|
||||||
utterances: [
|
|
||||||
"faire sauter les légumes à la poêle",
|
|
||||||
"saisir rapidement à feu vif en remuant sans cesse",
|
|
||||||
"faire revenir en remuant vivement dans une poêle très chaude",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: [
|
|
||||||
"sauté",
|
|
||||||
"sauteed",
|
|
||||||
"sautéed",
|
|
||||||
"sauteing",
|
|
||||||
"pan-fry",
|
|
||||||
"pan fried",
|
|
||||||
"pan-fried",
|
|
||||||
"stir-fry",
|
|
||||||
"pan searing",
|
|
||||||
"seared in a pan",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"sauté the vegetables in a pan",
|
|
||||||
"quickly sear over high heat, stirring constantly",
|
|
||||||
"cook briskly, stirring, in a very hot pan",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "blanch",
|
|
||||||
fr: {
|
|
||||||
synonyms: ["blanchir", "blanchissez", "blanchi", "blanchie", "blanchies", "blanchiment"],
|
|
||||||
utterances: [
|
|
||||||
"faire blanchir les légumes deux minutes dans l'eau bouillante",
|
|
||||||
"plonger brièvement dans l'eau bouillante puis directement dans l'eau glacée",
|
|
||||||
"cuire très rapidement à l'eau bouillante avant de stopper la cuisson au froid",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// "parboil" is folded in here rather than kept a separate technique —
|
|
||||||
// in home-cooking usage (as opposed to professional usage, where they
|
|
||||||
// can differ) it names the same "briefly pre-cook in boiling water"
|
|
||||||
// move blanching does.
|
|
||||||
synonyms: [
|
|
||||||
"blanch",
|
|
||||||
"blanches",
|
|
||||||
"blanched",
|
|
||||||
"blanching",
|
|
||||||
"parboil",
|
|
||||||
"parboiled",
|
|
||||||
"parboiling",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"blanch the vegetables for two minutes in boiling water",
|
|
||||||
"briefly plunge into boiling water then straight into ice water",
|
|
||||||
"cook very quickly in boiling water before stopping it cold",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "marinate",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"mariner",
|
|
||||||
"marinez",
|
|
||||||
"mariné",
|
|
||||||
"marinée",
|
|
||||||
"marinées",
|
|
||||||
"marinade",
|
|
||||||
"macérer",
|
|
||||||
"macérez",
|
|
||||||
"macération",
|
|
||||||
"faire mariner",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"laisser mariner la viande toute la nuit au réfrigérateur",
|
|
||||||
"faire tremper dans la sauce plusieurs heures avant cuisson pour parfumer",
|
|
||||||
"laisser reposer dans le mélange d'huile et d'épices avant de cuisiner",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "marinating for" — a word-prefix extension of "marinating"
|
|
||||||
// above (see `cook`'s comment for why that duplicates NER candidates
|
|
||||||
// — here it was even worse, misclassifying as `simmer`).
|
|
||||||
synonyms: [
|
|
||||||
"marinate",
|
|
||||||
"marinates",
|
|
||||||
"marinated",
|
|
||||||
"marinating",
|
|
||||||
"marinade",
|
|
||||||
"soak in the marinade",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"let the meat marinate overnight in the fridge",
|
|
||||||
"soak in the sauce for several hours before cooking to flavor it",
|
|
||||||
"let it sit in the oil and spice mixture before cooking",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "chop",
|
|
||||||
fr: {
|
|
||||||
// NOT "hacher grossièrement" — a word-prefix extension of "hacher"
|
|
||||||
// above (see `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: [
|
|
||||||
"hacher",
|
|
||||||
"hachez",
|
|
||||||
"haché",
|
|
||||||
"hachée",
|
|
||||||
"hachées",
|
|
||||||
"hachis",
|
|
||||||
"couper en morceaux",
|
|
||||||
"tailler en morceaux",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"hacher finement les oignons",
|
|
||||||
"couper en tout petits morceaux irréguliers au couteau",
|
|
||||||
"réduire les herbes en petits fragments avant de les ajouter",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "chop coarsely" — a word-prefix extension of "chop" above (see
|
|
||||||
// `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["chop", "chops", "chopped", "chopping", "roughly chop", "coarsely chopped"],
|
|
||||||
utterances: [
|
|
||||||
"finely chop the onions",
|
|
||||||
"cut into small, uneven pieces with a knife",
|
|
||||||
"break the herbs down into small bits before adding them",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "peel",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"éplucher",
|
|
||||||
"épluchez",
|
|
||||||
"épluché",
|
|
||||||
"épluchée",
|
|
||||||
"épluchées",
|
|
||||||
"épluchage",
|
|
||||||
"peler",
|
|
||||||
"pelez",
|
|
||||||
"pelé",
|
|
||||||
"pelée",
|
|
||||||
"pelées",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"éplucher les pommes de terre",
|
|
||||||
"retirer la peau des carottes avec un économe",
|
|
||||||
"ôter la pelure du fruit avant de le couper",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["peel", "peels", "peeled", "peeling", "pare", "pared", "paring"],
|
|
||||||
utterances: [
|
|
||||||
"peel the potatoes",
|
|
||||||
"remove the skin from the carrots with a peeler",
|
|
||||||
"take the skin off the fruit before cutting it",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "mince",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"émincer",
|
|
||||||
"émincez",
|
|
||||||
"émincé",
|
|
||||||
"émincée",
|
|
||||||
"émincées",
|
|
||||||
"ciseler",
|
|
||||||
"ciselez",
|
|
||||||
"ciselé",
|
|
||||||
"ciselée",
|
|
||||||
"ciselées",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"émincer l'oignon en fines lamelles",
|
|
||||||
"couper en très fines tranches régulières",
|
|
||||||
"détailler en lamelles aussi fines que possible",
|
|
||||||
// Without this, a short clause naming a different vegetable —
|
|
||||||
// "Émincer les tomates" — scored just above `melt`'s confidence
|
|
||||||
// threshold instead (a training-set-composition side effect of
|
|
||||||
// adding utterances elsewhere in this same pass, found by the full
|
|
||||||
// regression suite). A second example anchored on a different noun
|
|
||||||
// widens `mince`'s own region enough to reclaim it.
|
|
||||||
"émincer les tomates en fines rondelles",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "mince finely" — a word-prefix extension of "mince" above (see
|
|
||||||
// `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["mince", "minces", "minced", "mincing", "thinly slice", "finely mince"],
|
|
||||||
utterances: [
|
|
||||||
"mince the onion into thin strips",
|
|
||||||
"cut into very thin, even slices",
|
|
||||||
"slice into strips as thin as possible",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "mix",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"mélanger",
|
|
||||||
"mélangez",
|
|
||||||
"mélangé",
|
|
||||||
"mélangée",
|
|
||||||
"mélangées",
|
|
||||||
"mélange",
|
|
||||||
"brasser",
|
|
||||||
"brassez",
|
|
||||||
"amalgamer",
|
|
||||||
"amalgamez",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"mélanger tous les ingrédients dans un saladier",
|
|
||||||
"combiner le sucre et la farine ensemble",
|
|
||||||
"remuer jusqu'à obtenir une préparation homogène",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: [
|
|
||||||
"mix",
|
|
||||||
"mixes",
|
|
||||||
"mixed",
|
|
||||||
"mixing",
|
|
||||||
"combine",
|
|
||||||
"combined",
|
|
||||||
"blend",
|
|
||||||
"blended",
|
|
||||||
"blending",
|
|
||||||
"stir together",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"mix all the ingredients in a bowl",
|
|
||||||
"combine the sugar and flour together",
|
|
||||||
"stir until the mixture is smooth and even",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "whisk",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"fouetter",
|
|
||||||
"fouettez",
|
|
||||||
"fouetté",
|
|
||||||
"fouettée",
|
|
||||||
"fouettées",
|
|
||||||
"au fouet",
|
|
||||||
"battre au fouet",
|
|
||||||
"monter au fouet",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"fouetter les œufs et le sucre",
|
|
||||||
"battre vigoureusement au fouet jusqu'à ce que ça blanchisse",
|
|
||||||
"travailler énergiquement pour incorporer de l'air au mélange",
|
|
||||||
// Without these, "Fouetter les blancs en neige" misclassified as
|
|
||||||
// `foldIn` — its own training utterance below also happens to say
|
|
||||||
// "les blancs en neige", and node-nlp's intent classifier leaned on
|
|
||||||
// that shared noun phrase over the actual verb. The exact phrase
|
|
||||||
// itself is needed (not just a paraphrase of it) — a longer,
|
|
||||||
// differently-worded utterance alone wasn't enough to outweigh
|
|
||||||
// `foldIn`'s own close phrasing.
|
|
||||||
"fouetter les blancs en neige",
|
|
||||||
"fouetter les blancs en neige jusqu'à ce qu'ils soient fermes",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["whisk", "whisks", "whisked", "whisking", "beat", "whip", "whipped", "whipping"],
|
|
||||||
utterances: [
|
|
||||||
"whisk the eggs and sugar",
|
|
||||||
"beat vigorously with a whisk until pale",
|
|
||||||
"work it briskly to whip air into the mixture",
|
|
||||||
"whisk the egg whites until stiff peaks form",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "foldIn",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"incorporer",
|
|
||||||
"incorporez",
|
|
||||||
"incorporé",
|
|
||||||
"incorporée",
|
|
||||||
"incorporées",
|
|
||||||
// NOT "incorporer délicatement" — it's a superstring of "incorporer"
|
|
||||||
// above, so both would match the same text and hand
|
|
||||||
// `splitIntoClauses` two overlapping candidates for one mention
|
|
||||||
// (found via "Incorporer délicatement la farine" producing two
|
|
||||||
// duplicate matches instead of one).
|
|
||||||
"mélanger délicatement",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"incorporer délicatement les blancs en neige",
|
|
||||||
"ajouter en soulevant doucement la masse pour ne pas casser les bulles",
|
|
||||||
"mélanger tout doucement de bas en haut pour garder l'air emprisonné",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["fold in", "folds in", "folded in", "folding in", "gently fold", "fold gently"],
|
|
||||||
utterances: [
|
|
||||||
"gently fold in the beaten egg whites",
|
|
||||||
"add by gently lifting the batter so you don't knock the air out",
|
|
||||||
"very gently stir from the bottom up to keep the air trapped in",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "setAside",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"réserver",
|
|
||||||
"réservez",
|
|
||||||
"réservé",
|
|
||||||
"réservée",
|
|
||||||
"réservées",
|
|
||||||
"mettre de côté",
|
|
||||||
"laisser de côté",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"réserver au frais en attendant",
|
|
||||||
"mettre de côté pour plus tard",
|
|
||||||
"laisser attendre sur le plan de travail pendant la préparation du reste",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["set aside", "sets aside", "setting aside", "set it aside", "reserve", "reserved"],
|
|
||||||
utterances: [
|
|
||||||
"set aside in the fridge for now",
|
|
||||||
"put it aside for later",
|
|
||||||
"let it wait on the counter while you prepare the rest",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "season",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"assaisonner",
|
|
||||||
"assaisonnez",
|
|
||||||
"assaisonné",
|
|
||||||
"assaisonnée",
|
|
||||||
"assaisonnement",
|
|
||||||
"relever",
|
|
||||||
"relevez",
|
|
||||||
"épicer",
|
|
||||||
"épicez",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"assaisonner avec du sel et du poivre",
|
|
||||||
"rectifier le goût en ajoutant des épices",
|
|
||||||
"ajouter du sel selon votre goût avant de servir",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["season", "seasons", "seasoned", "seasoning", "spice it up", "add seasoning"],
|
|
||||||
utterances: [
|
|
||||||
"season with salt and pepper",
|
|
||||||
"adjust the taste by adding spices",
|
|
||||||
"add salt to taste before serving",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "drain",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"égoutter",
|
|
||||||
"égouttez",
|
|
||||||
"égoutté",
|
|
||||||
"égouttée",
|
|
||||||
"égouttées",
|
|
||||||
"essorer",
|
|
||||||
"essorez",
|
|
||||||
"essoré",
|
|
||||||
"essorée",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"égoutter les pâtes dans une passoire",
|
|
||||||
"verser dans une passoire pour retirer l'eau de cuisson",
|
|
||||||
"laisser l'excédent d'eau s'écouler avant de servir",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["drain", "drains", "drained", "draining", "strain", "strained", "straining"],
|
|
||||||
utterances: [
|
|
||||||
"drain the pasta in a colander",
|
|
||||||
"pour into a colander to remove the cooking water",
|
|
||||||
"let the excess water run off before serving",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "brown",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"faire revenir",
|
|
||||||
"faites revenir",
|
|
||||||
"faire dorer",
|
|
||||||
"faites dorer",
|
|
||||||
"colorer",
|
|
||||||
"colorez",
|
|
||||||
"faire colorer",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"faire revenir les oignons dans l'huile chaude",
|
|
||||||
"faire dorer la viande sur toutes les faces",
|
|
||||||
"saisir jusqu'à ce que la surface prenne une belle couleur caramel",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// Verb forms only (not bare "brown"), same reasoning the old regex
|
|
||||||
// doc comment gave — a bare "brown" false-positives on ingredient
|
|
||||||
// descriptions like "brown sugar"/"brown rice", which never get to
|
|
||||||
// the classifier since they're not step text, but keeping the
|
|
||||||
// synonym itself anchored costs nothing and stays consistent.
|
|
||||||
synonyms: ["browned", "browning"],
|
|
||||||
utterances: [
|
|
||||||
"brown the onions in hot oil",
|
|
||||||
"brown the meat on every side",
|
|
||||||
"sear until the surface turns a deep caramel color",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "rest",
|
|
||||||
fr: {
|
|
||||||
synonyms: ["reposer", "laisser reposer", "laissez reposer", "temps de repos"],
|
|
||||||
utterances: [
|
|
||||||
"laisser reposer la pâte trente minutes",
|
|
||||||
"laisser la viande se détendre hors du four avant de la découper",
|
|
||||||
"attendre quelques minutes avant de servir pour que les jus se répartissent",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// Anchored to "let ... rest"/"rest for" rather than bare "rest",
|
|
||||||
// same false-positive reasoning as `brown` above ("the rest of the").
|
|
||||||
synonyms: ["let it rest", "let them rest", "resting for", "rested for", "resting time"],
|
|
||||||
utterances: [
|
|
||||||
"let the dough rest for thirty minutes",
|
|
||||||
"let the meat relax outside the oven before carving it",
|
|
||||||
"wait a few minutes before serving so the juices redistribute",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "preheat",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"préchauffer",
|
|
||||||
"préchauffez",
|
|
||||||
"préchauffé",
|
|
||||||
"préchauffée",
|
|
||||||
// A pan already described as hot ("poêle chaude") implies it's
|
|
||||||
// been preheated, without the verb itself — the classic "Dans une
|
|
||||||
// poêle chaude, faire chauffer une noix de beurre" case (both
|
|
||||||
// `preheat` and `melt` in one instruction).
|
|
||||||
"poêle chaude",
|
|
||||||
"préchauffage",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"préchauffer le four à 180 degrés",
|
|
||||||
"mettre le four à chauffer avant d'y placer le plat",
|
|
||||||
"allumer le four à l'avance pour qu'il soit à température",
|
|
||||||
// A pan gets preheated too, not just an oven — without an example
|
|
||||||
// like this, "poêle" (which also appears throughout `panFry`'s own
|
|
||||||
// training utterances) biased the classifier toward `panFry` for
|
|
||||||
// any preheating clause that happens to mention a pan, found while
|
|
||||||
// testing against the classic "Préchauffer la poêle, puis faire
|
|
||||||
// fondre le beurre" case.
|
|
||||||
"préchauffer la poêle avant d'y verser l'huile",
|
|
||||||
"faire chauffer la poêle à vide quelques minutes",
|
|
||||||
// "poêle" + "feu vif" together still read as `panFry` (the act of
|
|
||||||
// actually cooking something in it) rather than `preheat` (getting
|
|
||||||
// it hot beforehand, nothing in it yet) without an example this
|
|
||||||
// close to that exact wording — found via "mettre la poêle sur feu
|
|
||||||
// vif" (no food mentioned at all) still classifying as panFry.
|
|
||||||
"mettre la poêle vide sur feu vif avant d'ajouter quoi que ce soit",
|
|
||||||
"mettre la poêle sur feu vif",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "preheating time" — a word-prefix extension of "preheating"
|
|
||||||
// above (see `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["preheat", "preheats", "preheated", "preheating", "hot pan"],
|
|
||||||
utterances: [
|
|
||||||
"preheat the oven to 180 degrees",
|
|
||||||
"turn the oven on to heat up before putting the dish in",
|
|
||||||
"switch the oven on ahead of time so it's up to temperature",
|
|
||||||
"preheat the pan before adding the oil",
|
|
||||||
"heat the empty pan for a few minutes first",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "bake",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"cuire au four",
|
|
||||||
"cuisson au four",
|
|
||||||
"enfourner",
|
|
||||||
"enfournez",
|
|
||||||
"au four",
|
|
||||||
"enfourné",
|
|
||||||
"enfournée",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"enfourner pendant quarante-cinq minutes",
|
|
||||||
"mettre au four jusqu'à ce que ce soit doré",
|
|
||||||
"cuire dans le four préchauffé jusqu'à ce que la surface soit ferme",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "baked in the oven" — a word-prefix extension of "baked" above
|
|
||||||
// (see `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["bake", "bakes", "baked", "baking", "in the oven", "oven-baked"],
|
|
||||||
utterances: [
|
|
||||||
"bake for forty-five minutes",
|
|
||||||
"put it in the oven until golden",
|
|
||||||
"cook in the preheated oven until the surface is firm",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "plate",
|
|
||||||
fr: {
|
|
||||||
// NOT "dressage de l'assiette" — a word-prefix extension of
|
|
||||||
// "dressage" above (see `cook`'s comment for why that duplicates NER
|
|
||||||
// candidates).
|
|
||||||
synonyms: ["dresser", "dressez", "dressage", "disposer dans l'assiette"],
|
|
||||||
utterances: [
|
|
||||||
"dresser harmonieusement dans les assiettes",
|
|
||||||
"disposer joliment sur l'assiette avant de servir",
|
|
||||||
"présenter avec soin au centre de l'assiette",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "plate up"/"plated nicely" — both are word-prefix extensions of
|
|
||||||
// "plate"/"plated" above (see `cook`'s comment for why that
|
|
||||||
// duplicates NER candidates).
|
|
||||||
synonyms: ["plate", "plates", "plated", "plating"],
|
|
||||||
utterances: [
|
|
||||||
"plate it up nicely",
|
|
||||||
"arrange it neatly on the plate before serving",
|
|
||||||
"present it carefully in the center of the plate",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "coat",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"napper",
|
|
||||||
"nappez",
|
|
||||||
"nappé",
|
|
||||||
"nappée",
|
|
||||||
"nappées",
|
|
||||||
"nappage",
|
|
||||||
"enrober",
|
|
||||||
"enrobez",
|
|
||||||
"enrobé",
|
|
||||||
"enrobée",
|
|
||||||
"enrobées",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"napper le gâteau de chocolat fondu",
|
|
||||||
"recouvrir uniformément d'une fine couche de sauce",
|
|
||||||
"verser la sauce par-dessus pour bien enrober",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "coat evenly" — a word-prefix extension of "coat" above (see
|
|
||||||
// `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["coat", "coats", "coated", "coating", "dredge", "dredged", "dredging"],
|
|
||||||
utterances: [
|
|
||||||
"coat the cake with melted chocolate",
|
|
||||||
"cover evenly with a thin layer of sauce",
|
|
||||||
"pour the sauce over it so it's well covered",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
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();
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
createRecipeSchema,
|
createRecipeSchema,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
listRecipesSchema,
|
listRecipesSchema,
|
||||||
|
submitTechStepCorrectionSchema,
|
||||||
updateRecipeSchema,
|
updateRecipeSchema,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
|
@ -17,6 +18,10 @@ import {
|
||||||
removeFavorite,
|
removeFavorite,
|
||||||
updateRecipe,
|
updateRecipe,
|
||||||
} from "./recipe.service.js";
|
} 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). */
|
/** 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();
|
export const recipeRouter = Router();
|
||||||
|
|
@ -30,6 +35,15 @@ function parseRecipeId(rawId: string | undefined): number {
|
||||||
return id;
|
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(
|
recipeRouter.get(
|
||||||
"/",
|
"/",
|
||||||
requireAuth,
|
requireAuth,
|
||||||
|
|
@ -109,3 +123,29 @@ recipeRouter.delete(
|
||||||
res.status(204).end();
|
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));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,29 @@ function recipeInclude(viewerId: number) {
|
||||||
steps: {
|
steps: {
|
||||||
orderBy: { order: "asc" },
|
orderBy: { order: "asc" },
|
||||||
include: {
|
include: {
|
||||||
techSteps: { orderBy: { order: "asc" }, include: { techStep: true } },
|
techSteps: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: {
|
||||||
|
techStep: true,
|
||||||
|
// Same `allergies`/`diets` nesting as this function's own
|
||||||
|
// top-level `ingredients` include above — reused by
|
||||||
|
// `toIngredientView` so a mentioned ingredient resolves to the
|
||||||
|
// exact same `IngredientView` shape as the recipe's main
|
||||||
|
// ingredient list, not a second, thinner shape.
|
||||||
|
ingredients: {
|
||||||
|
include: {
|
||||||
|
ingredient: {
|
||||||
|
include: {
|
||||||
|
allergies: { include: { allergy: { include: { category: true } } } },
|
||||||
|
diets: { include: { diet: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
unit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
utensils: { include: { utensil: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
diets: { include: { diet: true } },
|
diets: { include: { diet: true } },
|
||||||
|
|
@ -56,11 +78,13 @@ function recipeInclude(viewerId: number) {
|
||||||
type RecipeWithDetails = Prisma.RecipeGetPayload<{
|
type RecipeWithDetails = Prisma.RecipeGetPayload<{
|
||||||
include: ReturnType<typeof recipeInclude>;
|
include: ReturnType<typeof recipeInclude>;
|
||||||
}>;
|
}>;
|
||||||
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
/** 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. */
|
||||||
type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
|
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. */
|
/** 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). */
|
||||||
function toUnitView(unit: UnitWithDetails): UnitView {
|
export function toUnitView(unit: UnitWithDetails): UnitView {
|
||||||
return {
|
return {
|
||||||
id: unit.id,
|
id: unit.id,
|
||||||
key: unit.key,
|
key: unit.key,
|
||||||
|
|
@ -69,8 +93,8 @@ function toUnitView(unit: UnitWithDetails): UnitView {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */
|
/** 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`. */
|
||||||
function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
|
export function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
|
||||||
return {
|
return {
|
||||||
id: ingredient.id,
|
id: ingredient.id,
|
||||||
key: ingredient.key,
|
key: ingredient.key,
|
||||||
|
|
@ -132,19 +156,48 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
|
||||||
* existed) still has a perfectly good match to show, just without the
|
* existed) still has a perfectly good match to show, just without the
|
||||||
* wider highlight, so those two are included only when both are present
|
* 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".
|
* 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"],
|
techSteps: RecipeWithDetails["steps"][number]["techSteps"],
|
||||||
): StepTechStepView[] {
|
): StepTechStepView[] {
|
||||||
const views: StepTechStepView[] = [];
|
const views: StepTechStepView[] = [];
|
||||||
for (const stepTechStep of techSteps) {
|
for (const stepTechStep of techSteps) {
|
||||||
const { start, end, contextStart, contextEnd, techStep } = stepTechStep;
|
const { start, end, contextStart, contextEnd, techStep, source, ingredients, utensils } =
|
||||||
|
stepTechStep;
|
||||||
if (start === null || end === null) continue;
|
if (start === null || end === null) continue;
|
||||||
views.push({
|
views.push({
|
||||||
techStep: { id: techStep.id, key: techStep.key },
|
techStep: { id: techStep.id, key: techStep.key },
|
||||||
start,
|
start,
|
||||||
end,
|
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 } : {}),
|
...(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;
|
return views;
|
||||||
|
|
@ -539,6 +592,22 @@ async function createRecipeInternal(
|
||||||
contextStart: match.contextStart,
|
contextStart: match.contextStart,
|
||||||
contextEnd: match.contextEnd,
|
contextEnd: match.contextEnd,
|
||||||
order,
|
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,
|
||||||
|
})),
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
getSources,
|
getSources,
|
||||||
getTechSteps,
|
getTechSteps,
|
||||||
getUnits,
|
getUnits,
|
||||||
|
getUtensils,
|
||||||
} from "./reference.service.js";
|
} 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(
|
referenceRouter.get(
|
||||||
"/sources",
|
"/sources",
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
wrapAsyncHandler(async (_req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
SourceView,
|
SourceView,
|
||||||
TechStepView,
|
TechStepView,
|
||||||
UnitView,
|
UnitView,
|
||||||
|
UtensilView,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
|
|
||||||
|
|
@ -85,6 +86,19 @@ export async function getTechSteps(): Promise<TechStepView[]> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All reference cooking utensils, ordered by key (see {@link getDiets} for
|
||||||
|
* why) — small, static list (see `reference-seed-data.ts`'s `UTENSILS`),
|
||||||
|
* same bare `id`/`key` shape as {@link getTechSteps}.
|
||||||
|
*/
|
||||||
|
export async function getUtensils(): Promise<UtensilView[]> {
|
||||||
|
try {
|
||||||
|
return await prisma.utensil.findMany({ orderBy: { key: "asc" } });
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see getDiets()'s catch comment above
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every implemented recipe source, ordered by name (not `key` — unlike
|
* Every implemented recipe source, ordered by name (not `key` — unlike
|
||||||
* every other reference catalog, `name` here *is* the display string a
|
* every other reference catalog, `name` here *is* the display string a
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,10 +11,8 @@ import {
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import { findImportedRecipeIds } from "../../db/recipe-source-sync.js";
|
import { findImportedRecipeIds } from "../../db/recipe-source-sync.js";
|
||||||
import {
|
import {
|
||||||
type IngredientMatchEntry,
|
|
||||||
loadIngredientCatalog,
|
loadIngredientCatalog,
|
||||||
loadUnitCatalog,
|
loadUnitCatalog,
|
||||||
type UnitMatchEntry,
|
|
||||||
} from "../../lib/recipe-matching/ingredient-matcher.js";
|
} from "../../lib/recipe-matching/ingredient-matcher.js";
|
||||||
import {
|
import {
|
||||||
mergeDuplicateIngredients,
|
mergeDuplicateIngredients,
|
||||||
|
|
@ -29,7 +27,7 @@ import { RecipeSourceError } from "../../lib/recipe-sources/recipe-source-errors
|
||||||
import { getRecipeSource } from "../../lib/recipe-sources/recipe-source-registry.js";
|
import { getRecipeSource } from "../../lib/recipe-sources/recipe-source-registry.js";
|
||||||
import { getHouseSourceIds } from "../house/house.service.js";
|
import { getHouseSourceIds } from "../house/house.service.js";
|
||||||
import { createImportedRecipe } from "../recipe/recipe.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
|
* Browsing, previewing, and importing a household's *enabled* external
|
||||||
|
|
@ -141,10 +139,13 @@ export async function browseSource(
|
||||||
* techniques with their exact matched span (`matchTechStepSpans`, the same
|
* techniques with their exact matched span (`matchTechStepSpans`, the same
|
||||||
* function `recipe.service.ts` uses at real save time — see its doc
|
* function `recipe.service.ts` uses at real save time — see its doc
|
||||||
* comment), all against `adapter.locale`'s catalogs. Ingredient/unit
|
* comment), all against `adapter.locale`'s catalogs. Ingredient/unit
|
||||||
* matching itself only has English data today (see `ingredient-matcher.ts`);
|
* matching has data for `"en"`/`"fr"` today (see `ingredient-matcher.ts`);
|
||||||
* a non-English-locale source simply gets `ingredient`/`unit: null` on
|
* `loadIngredientCatalog`/`loadUnitCatalog` are always called with
|
||||||
* every line, the same graceful "no matching-language data" degradation
|
* `adapter.locale` directly, never specially skipped for a particular
|
||||||
* `translateRecipe` already has.
|
* 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 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.
|
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
|
||||||
|
|
@ -177,10 +178,8 @@ export async function previewSourceItem(
|
||||||
matches: await techStepClassifier.matchTechStepSpans(step.description, adapter.locale),
|
matches: await techStepClassifier.matchTechStepSpans(step.description, adapter.locale),
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
adapter.locale === "en"
|
loadIngredientCatalog(adapter.locale),
|
||||||
? loadIngredientCatalog()
|
loadUnitCatalog(adapter.locale),
|
||||||
: Promise.resolve<IngredientMatchEntry[]>([]),
|
|
||||||
adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve<UnitMatchEntry[]>([]),
|
|
||||||
prisma.techStep.findMany({ select: { id: true, key: true } }),
|
prisma.techStep.findMany({ select: { id: true, key: true } }),
|
||||||
]);
|
]);
|
||||||
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
|
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
|
||||||
|
|
@ -189,10 +188,16 @@ export async function previewSourceItem(
|
||||||
parsed.ingredients,
|
parsed.ingredients,
|
||||||
ingredientCatalog,
|
ingredientCatalog,
|
||||||
unitCatalog,
|
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 ingredientById = new Map(ingredientViews.map((view) => [view.id, view]));
|
||||||
const unitById = new Map(unitViews.map((view) => [view.id, view]));
|
const unitById = new Map(unitViews.map((view) => [view.id, view]));
|
||||||
|
const utensilById = new Map(utensilViews.map((view) => [view.id, view]));
|
||||||
|
|
||||||
// A source's raw ingredient lines aren't deduplicated by the matcher —
|
// 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
|
// two different lines (e.g. "Egg Yolks"/"Eggs") can resolve to the same
|
||||||
|
|
@ -225,6 +230,36 @@ export async function previewSourceItem(
|
||||||
end: match.end,
|
end: match.end,
|
||||||
contextStart: match.contextStart,
|
contextStart: match.contextStart,
|
||||||
contextEnd: match.contextEnd,
|
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 }]
|
||||||
|
: [];
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,123 @@
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
import { prisma } from "../db/prisma.js";
|
import { prisma } from "../db/prisma.js";
|
||||||
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
|
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
|
||||||
|
import { renumberStepTechSteps } from "../modules/recipe/recipe-tech-step-correction.service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-off maintenance script: recomputes every existing `Step`'s
|
* Recomputes every existing `Step`'s `"auto"`-sourced `StepTechStep`
|
||||||
* `StepTechStep` sequence against the *current* classifier
|
* entries against the *current* classifier
|
||||||
* (`tech-step-matcher.ts`/`tech-step-training-data.ts`), the same way
|
* (`tech-step-matcher.ts`, delegating to `services/tech-step-intent-service`),
|
||||||
* `updateRecipe` does when a user resaves a recipe through the UI —
|
* 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
|
* 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,
|
* no persisted per-recipe locale to recover for a step that already
|
||||||
* so this matches real resave behavior exactly rather than guessing).
|
* exists, so this matches real resave behavior exactly rather than
|
||||||
|
* guessing).
|
||||||
*
|
*
|
||||||
* Needed because tech-step detection only ever runs at create/update time
|
* Needed because tech-step detection only ever runs at create/update time
|
||||||
* (`recipe.service.ts`'s `matchStepsTechSteps`), never retroactively — a
|
* (`recipe.service.ts`'s `matchStepsTechSteps`), never retroactively — a
|
||||||
* step saved before a classifier/corpus change (new vocabulary, or the
|
* step saved before a classifier/corpus change (new vocabulary, or the
|
||||||
* `contextStart`/`contextEnd` columns this same session added) keeps
|
* `contextStart`/`contextEnd` columns a previous session added) keeps
|
||||||
* whatever it was matched with at the time until it's next resaved. Run
|
* whatever it was matched with at the time until it's next resaved.
|
||||||
* this after a corpus change to bring every existing step in sync without
|
|
||||||
* asking users to open and resave every recipe by hand:
|
|
||||||
*
|
*
|
||||||
* pnpm --filter api exec tsx src/scripts/backfill-tech-steps.ts
|
* `"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.
|
||||||
*
|
*
|
||||||
* Safe to re-run: each step's technique sequence is fully replaced (delete
|
* Exported (not just called from this file's own CLI guard below) so
|
||||||
* + recreate) from the classifier's current output, same as a real edit —
|
* `retrain-tech-steps.ts` can run it as one step of its own larger
|
||||||
* running it twice in a row with no corpus change in between is a no-op.
|
* 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.
|
||||||
*/
|
*/
|
||||||
async function backfillTechSteps(): Promise<void> {
|
export async function backfillTechSteps(): Promise<{ total: number; changed: number }> {
|
||||||
const steps = await prisma.step.findMany({ select: { id: true, description: true } });
|
const steps = await prisma.step.findMany({ select: { id: true, description: true } });
|
||||||
console.info(`Recomputing tech steps for ${steps.length} step(s)...`);
|
console.info(`Recomputing tech steps for ${steps.length} step(s)...`);
|
||||||
|
|
||||||
let changed = 0;
|
let changed = 0;
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
const matches = await techStepClassifier.matchTechStepSpans(step.description, "fr");
|
const matches = await techStepClassifier.matchTechStepSpans(step.description, "fr");
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.stepTechStep.deleteMany({ where: { stepId: step.id } }),
|
await prisma.$transaction(async (tx) => {
|
||||||
prisma.stepTechStep.createMany({
|
const manualRows = await tx.stepTechStep.findMany({
|
||||||
data: matches.map((match, order) => ({
|
where: { stepId: step.id, source: "manual" },
|
||||||
stepId: step.id,
|
});
|
||||||
techStepId: match.techStepId,
|
|
||||||
order,
|
const nonOverlappingMatches = matches.filter(
|
||||||
start: match.start,
|
(match) =>
|
||||||
end: match.end,
|
!manualRows.some(
|
||||||
contextStart: match.contextStart,
|
(manual) =>
|
||||||
contextEnd: match.contextEnd,
|
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;
|
changed += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.info(`Done — ${changed} step(s) recomputed.`);
|
console.info(`Done — ${changed} step(s) recomputed.`);
|
||||||
|
return { total: steps.length, changed };
|
||||||
}
|
}
|
||||||
|
|
||||||
backfillTechSteps()
|
// Only runs when this file is executed directly (`tsx
|
||||||
.then(() => prisma.$disconnect())
|
// src/scripts/backfill-tech-steps.ts`), not when `retrain-tech-steps.ts`
|
||||||
.catch(async (err) => {
|
// imports `backfillTechSteps` above — the standard ESM "is this the entry
|
||||||
console.error(err);
|
// module" check, first needed in this codebase by that new script; every
|
||||||
await prisma.$disconnect();
|
// prior script here (`seed-runtime.ts`) was always only ever run directly,
|
||||||
process.exit(1);
|
// 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);
|
||||||
|
});
|
||||||
|
|
@ -9,22 +9,45 @@ import { registerAllRecipeSources } from "./sources/index.js";
|
||||||
// doesn't happen inside app.ts/createServer() itself.
|
// doesn't happen inside app.ts/createServer() itself.
|
||||||
registerAllRecipeSources();
|
registerAllRecipeSources();
|
||||||
|
|
||||||
// Trains the tech-step classifier (and pays node-nlp's own one-time lazy
|
/**
|
||||||
// setup cost — see `TechStepClassifierService.warmUp`) before accepting
|
* Trains the tech-step classifier (a `POST /v1/train` round-trip per locale
|
||||||
// any traffic, so the first real recipe save/preview isn't the one stuck
|
* to `services/tech-step-intent-service` — see
|
||||||
// waiting several seconds for it.
|
* `TechStepClassifierService.warmUp`) before accepting any traffic, so the
|
||||||
try {
|
* first real recipe save/preview isn't the one stuck waiting for it.
|
||||||
await techStepClassifier.warmUp();
|
*
|
||||||
} catch (err) {
|
* Retried with exponential backoff: in Docker Compose, `app`'s own
|
||||||
// Not fatal to startup — a failed warm-up just means the *next* call
|
* `depends_on: tech-step-intent-service: condition: service_healthy`
|
||||||
// retries training itself (see `_ensureTrained`'s own retry-on-failure
|
* (`docker-compose.yml`) already means that service is up by the time this
|
||||||
// comment), same graceful-degrade posture as everywhere else training
|
* runs, but native dev (`pnpm dev:api`, no Compose ordering at all) can
|
||||||
// failures surface. Still worth a loud log: this shouldn't normally fail.
|
* easily start this before the intent service has finished loading its
|
||||||
logger.error("Tech-step classifier warm-up failed", {
|
* spaCy models — a transient connection failure here shouldn't need a
|
||||||
error: err instanceof Error ? err.message : String(err),
|
* 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();
|
const server = createServer();
|
||||||
|
|
||||||
server.listen(env.PORT, () => {
|
server.listen(env.PORT, () => {
|
||||||
|
|
|
||||||
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-sources/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";
|
import { theMealDbAdapter } from "./the-meal-db.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers every concrete, *browsable* `RecipeSourceAdapter` this app
|
* Registers every concrete, *browsable* `RecipeSourceAdapter` this app
|
||||||
* ships with into the shared in-memory registry
|
* ships with into the shared in-memory registry (`recipe-source-registry.ts`)
|
||||||
* (`recipe-source-registry.ts`) — currently just `theMealDbAdapter`.
|
* — `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter` and
|
||||||
* Called once, explicitly, by the two real entry points that need the
|
* `mangerBougerAdapter`. Called once, explicitly, by the two real entry
|
||||||
* registry populated:
|
* points that need the registry populated:
|
||||||
*
|
*
|
||||||
* - `server.ts` — the running API process, before it starts listening.
|
* - `server.ts` — the running API process, before it starts listening.
|
||||||
* - `prisma/seed.ts` — so `syncRecipeSources` has something to mirror into
|
* - `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
|
* 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`).
|
* 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
|
* 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
|
* specialized per scraped website, not a household-toggleable `Source` in
|
||||||
* would use it internally), not a household-toggleable `Source` in its own
|
* its own right: nobody can meaningfully "trust" or "enable" a generic
|
||||||
* right: nobody can meaningfully "trust" or "enable" a generic parsing
|
* parsing mechanism the way they can a named website. `marmitonAdapter`
|
||||||
* mechanism the way they can a named website. Until real per-site adapters
|
* (marmiton.ts), `sevenFiftyGAdapter` (750g.ts) and `mangerBougerAdapter`
|
||||||
* exist, it's called directly (e.g. a future "import from a pasted URL"
|
* (manger-bouger.ts) are exactly that specialization, one per site — the
|
||||||
* flow), never through this registry.
|
* concrete adapters its own doc comment anticipated ("a concrete adapter
|
||||||
|
* for a specific site would use it internally").
|
||||||
*/
|
*/
|
||||||
export function registerAllRecipeSources(): void {
|
export function registerAllRecipeSources(): void {
|
||||||
registerRecipeSource(theMealDbAdapter);
|
registerRecipeSource(theMealDbAdapter);
|
||||||
|
registerRecipeSource(marmitonAdapter);
|
||||||
|
registerRecipeSource(sevenFiftyGAdapter);
|
||||||
|
registerRecipeSource(mangerBougerAdapter);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,17 @@ interface SchemaOrgRecipe {
|
||||||
url?: string;
|
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[] = [];
|
const blocks: unknown[] = [];
|
||||||
for (const match of html.matchAll(JSON_LD_SCRIPT_PATTERN)) {
|
for (const match of html.matchAll(JSON_LD_SCRIPT_PATTERN)) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -167,6 +176,17 @@ function flattenInstructions(instructions: SchemaOrgRecipe["recipeInstructions"]
|
||||||
* `fetchDetail`'s `externalId` is simply the target URL itself, not an id
|
* `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
|
* from a prior `list()` call. A future "import from URL" flow would call
|
||||||
* `fetchDetail(pastedUrl)` directly.
|
* `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<{
|
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{
|
||||||
html: string;
|
html: string;
|
||||||
|
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
50
apps/api/src/types/node-nlp.d.ts
vendored
50
apps/api/src/types/node-nlp.d.ts
vendored
|
|
@ -1,50 +0,0 @@
|
||||||
/**
|
|
||||||
* Minimal ambient typing for `node-nlp` (no official/DefinitelyTyped types
|
|
||||||
* exist for it) — declares only the `NlpManager` surface
|
|
||||||
* `tech-step-matcher.ts` actually calls, verified against the real
|
|
||||||
* package (v4.27.0) rather than the library's full documented API, which
|
|
||||||
* this repo doesn't use the rest of.
|
|
||||||
*/
|
|
||||||
declare module "node-nlp" {
|
|
||||||
/** Constructor options this repo passes — `NlpManager` accepts more, only what's used here is typed. */
|
|
||||||
export interface NlpManagerOptions {
|
|
||||||
languages?: string[];
|
|
||||||
forceNER?: boolean;
|
|
||||||
nlu?: { log?: boolean };
|
|
||||||
ner?: { threshold?: number };
|
|
||||||
/** Defaults to `true` — persists the trained model to `modelFileName` (default `model.nlp`, in `process.cwd()`). See `tech-step-matcher.ts`'s own constructor comment for why this repo always sets it `false`. */
|
|
||||||
autoSave?: boolean;
|
|
||||||
/** Defaults to `true` — loads from `modelFileName` instead of training fresh if that file already exists. Always `false` here, same reasoning as `autoSave`. */
|
|
||||||
autoLoad?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One entity `NlpManager.process`'s result reports — see `tech-step-matcher.ts`'s own `NerEntity` for the subset this repo reads. */
|
|
||||||
export interface NlpEntity {
|
|
||||||
entity: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
type: string;
|
|
||||||
accuracy?: number;
|
|
||||||
sourceText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `NlpManager.process`'s result — trimmed to the fields this repo reads (the real object carries many more). */
|
|
||||||
export interface NlpProcessResult {
|
|
||||||
intent: string;
|
|
||||||
score: number;
|
|
||||||
entities: NlpEntity[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NlpManager {
|
|
||||||
public constructor(options?: NlpManagerOptions);
|
|
||||||
public addNamedEntityText(
|
|
||||||
entityName: string,
|
|
||||||
optionName: string,
|
|
||||||
languages: string[],
|
|
||||||
texts: string[],
|
|
||||||
): void;
|
|
||||||
public addDocument(locale: string, utterance: string, intent: string): void;
|
|
||||||
public train(): Promise<void>;
|
|
||||||
public process(locale: string, text: string): Promise<NlpProcessResult>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { INGREDIENT_LABEL_SYNONYMS_EN } from "@batch-cooking/shared";
|
import { INGREDIENT_LABEL_SYNONYMS_EN, INGREDIENT_LABEL_SYNONYMS_FR } from "@batch-cooking/shared";
|
||||||
import { expect } from "chai";
|
import { expect } from "chai";
|
||||||
import { prisma } from "../../src/db/prisma.js";
|
import { prisma } from "../../src/db/prisma.js";
|
||||||
import {
|
import {
|
||||||
extractQuantity,
|
extractQuantity,
|
||||||
|
findIngredientMentions,
|
||||||
type IngredientMatchEntry,
|
type IngredientMatchEntry,
|
||||||
loadIngredientCatalog,
|
loadIngredientCatalog,
|
||||||
loadUnitCatalog,
|
loadUnitCatalog,
|
||||||
|
|
@ -90,6 +91,48 @@ describe("ingredient-matcher", () => {
|
||||||
const onionB: IngredientMatchEntry = { ingredientId: 21, label: "Onion" };
|
const onionB: IngredientMatchEntry = { ingredientId: 21, label: "Onion" };
|
||||||
expect(matchIngredientName("onion", [onionB, onionA])).to.equal(20);
|
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", () => {
|
describe("matchUnit", () => {
|
||||||
|
|
@ -117,10 +160,14 @@ describe("ingredient-matcher", () => {
|
||||||
expect(matchUnit("TBSP", catalog)).to.equal(tablespoon.unitId);
|
expect(matchUnit("TBSP", catalog)).to.equal(tablespoon.unitId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only looks at the first word — ignores trailing text", () => {
|
it("ignores trailing text after the unit word", () => {
|
||||||
expect(matchUnit("cup flour", catalog)).to.equal(cup.unitId);
|
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", () => {
|
it("doesn't match a short abbreviation inside an unrelated word", () => {
|
||||||
// "g" alone must not match "grated" — whole-token comparison.
|
// "g" alone must not match "grated" — whole-token comparison.
|
||||||
expect(matchUnit("grated", catalog)).to.equal(null);
|
expect(matchUnit("grated", catalog)).to.equal(null);
|
||||||
|
|
@ -137,6 +184,35 @@ describe("ingredient-matcher", () => {
|
||||||
it("returns null for an empty string", () => {
|
it("returns null for an empty string", () => {
|
||||||
expect(matchUnit("", catalog)).to.equal(null);
|
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", () => {
|
describe("extractQuantity", () => {
|
||||||
|
|
@ -191,6 +267,100 @@ describe("ingredient-matcher", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("findIngredientMentions", () => {
|
||||||
|
const butter: IngredientMatchEntry = { ingredientId: 1, label: "Butter" };
|
||||||
|
const flour: IngredientMatchEntry = { ingredientId: 2, label: "Flour" };
|
||||||
|
const egg: IngredientMatchEntry = { ingredientId: 3, label: "Egg" };
|
||||||
|
const catalog = [butter, flour, egg];
|
||||||
|
const gram: UnitMatchEntry = { unitId: 1, synonyms: ["g", "gram", "grams"] };
|
||||||
|
const unitCatalog = [gram];
|
||||||
|
|
||||||
|
it("finds a single mention with no quantity or unit", () => {
|
||||||
|
const text = "melt the butter";
|
||||||
|
const mentions = findIngredientMentions(text, catalog, unitCatalog);
|
||||||
|
expect(mentions).to.have.length(1);
|
||||||
|
const [mention] = mentions;
|
||||||
|
expect(mention?.ingredientId).to.equal(butter.ingredientId);
|
||||||
|
expect(text.slice(mention?.start, mention?.end)).to.equal("butter");
|
||||||
|
expect(mention?.quantity).to.equal(null);
|
||||||
|
expect(mention?.unitId).to.equal(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a quantity and unit glued directly to the ingredient ("200g butter")', () => {
|
||||||
|
const text = "add 200g butter";
|
||||||
|
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
|
||||||
|
expect(mention?.ingredientId).to.equal(butter.ingredientId);
|
||||||
|
expect(mention?.quantity).to.equal(200);
|
||||||
|
expect(mention?.unitId).to.equal(gram.unitId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds several mentions in reading order, non-overlapping", () => {
|
||||||
|
const text = "melt the butter then add the flour and an egg";
|
||||||
|
const mentions = findIngredientMentions(text, catalog, unitCatalog);
|
||||||
|
expect(mentions.map((mention) => mention.ingredientId)).to.deep.equal([
|
||||||
|
butter.ingredientId,
|
||||||
|
flour.ingredientId,
|
||||||
|
egg.ingredientId,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case- and accent-insensitive", () => {
|
||||||
|
const text = "MELT THE BUTTER";
|
||||||
|
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
|
||||||
|
expect(mention?.ingredientId).to.equal(butter.ingredientId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an unrelated number earlier in the text (e.g. an oven temperature)", () => {
|
||||||
|
const text = "preheat to 180 degrees then add the egg";
|
||||||
|
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
|
||||||
|
expect(mention?.ingredientId).to.equal(egg.ingredientId);
|
||||||
|
expect(mention?.quantity).to.equal(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array when nothing in the catalog is mentioned", () => {
|
||||||
|
expect(findIngredientMentions("stir well", catalog, unitCatalog)).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array for empty text", () => {
|
||||||
|
expect(findIngredientMentions("", catalog, unitCatalog)).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("locale: fr", () => {
|
||||||
|
const beurre: IngredientMatchEntry = { ingredientId: 10, label: "Beurre" };
|
||||||
|
const farine: IngredientMatchEntry = { ingredientId: 11, label: "Farine" };
|
||||||
|
const frCatalog = [beurre, farine];
|
||||||
|
const gramme: UnitMatchEntry = { unitId: 40, synonyms: ["g", "gr", "gramme", "grammes"] };
|
||||||
|
const cuillereASoupe: UnitMatchEntry = {
|
||||||
|
unitId: 41,
|
||||||
|
synonyms: ["cuillère à soupe", "cuillères à soupe", "càs"],
|
||||||
|
};
|
||||||
|
const frUnitCatalog = [gramme, cuillereASoupe];
|
||||||
|
|
||||||
|
it("resolves a quantity and unit before the ingredient, connected by 'de'", () => {
|
||||||
|
const text = "faire fondre 50g de beurre";
|
||||||
|
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
|
||||||
|
expect(mention?.ingredientId).to.equal(beurre.ingredientId);
|
||||||
|
expect(mention?.quantity).to.equal(50);
|
||||||
|
expect(mention?.unitId).to.equal(gramme.unitId);
|
||||||
|
expect(text.slice(mention?.start, mention?.end)).to.equal("beurre");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a multi-word unit connected by "d\'"', () => {
|
||||||
|
const text = "ajouter 2 cuillères à soupe de farine";
|
||||||
|
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
|
||||||
|
expect(mention?.ingredientId).to.equal(farine.ingredientId);
|
||||||
|
expect(mention?.quantity).to.equal(2);
|
||||||
|
expect(mention?.unitId).to.equal(cuillereASoupe.unitId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is accent-insensitive", () => {
|
||||||
|
const text = "FAIRE FONDRE LE BEURRE";
|
||||||
|
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
|
||||||
|
expect(mention?.ingredientId).to.equal(beurre.ingredientId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("loadIngredientCatalog / loadUnitCatalog", () => {
|
describe("loadIngredientCatalog / loadUnitCatalog", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await resetDatabase();
|
await resetDatabase();
|
||||||
|
|
@ -239,5 +409,51 @@ describe("ingredient-matcher", () => {
|
||||||
const cupEntry = catalog.find((entry) => entry.unitId === cup.id);
|
const cupEntry = catalog.find((entry) => entry.unitId === cup.id);
|
||||||
expect(cupEntry?.synonyms).to.deep.equal(["cup", "cups"]);
|
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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,9 @@ describe("recipe-translation", () => {
|
||||||
// `translateRecipeSteps` now goes through `techStepClassifier` (a
|
// `translateRecipeSteps` now goes through `techStepClassifier` (a
|
||||||
// trained model, not a pure regex test against a caller-supplied
|
// trained model, not a pure regex test against a caller-supplied
|
||||||
// mapping list — see `tech-step-matcher.ts`), so these tests exercise
|
// mapping list — see `tech-step-matcher.ts`), so these tests exercise
|
||||||
// the real training corpus (`tech-step-training-data.ts`) against a real
|
// the real training corpus (`services/tech-step-intent-service`'s
|
||||||
// `TechStep` catalog rather than synthetic fixtures — same posture
|
// `training_data.py`) against a real `TechStep` catalog rather than
|
||||||
|
// synthetic fixtures — same posture
|
||||||
// `tech-step-matcher.test.ts`'s own `techStepClassifier` describe block
|
// `tech-step-matcher.test.ts`'s own `techStepClassifier` describe block
|
||||||
// takes, for the same reason.
|
// takes, for the same reason.
|
||||||
describe("translateRecipeSteps", () => {
|
describe("translateRecipeSteps", () => {
|
||||||
|
|
@ -453,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 = {
|
const recipe: ParsedRecipe = {
|
||||||
...buildParsedRecipe(["Faire mijoter à feu doux"]),
|
...buildParsedRecipe(["Faire mijoter à feu doux"]),
|
||||||
ingredients: [
|
ingredients: [
|
||||||
|
|
@ -461,12 +502,12 @@ describe("recipe-translation", () => {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const translated = await translateRecipe(recipe, "fr");
|
const translated = await translateRecipe(recipe, "de");
|
||||||
|
|
||||||
expect(translated.ingredients).to.deep.equal([
|
expect(translated.ingredients).to.deep.equal([
|
||||||
{
|
{
|
||||||
rawText: "1 cup onions, chopped",
|
rawText: "1 cup onions, chopped",
|
||||||
quantity: null,
|
quantity: 1,
|
||||||
unit: null,
|
unit: null,
|
||||||
name: "onions",
|
name: "onions",
|
||||||
ingredientId: null,
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -118,13 +118,16 @@ describe("tech-step-matcher", () => {
|
||||||
// `techStepClassifier` is the one shared singleton (see
|
// `techStepClassifier` is the one shared singleton (see
|
||||||
// tech-step-matcher.ts's own doc comment on why) — these tests
|
// tech-step-matcher.ts's own doc comment on why) — these tests
|
||||||
// exercise it against the real training corpus
|
// exercise it against the real training corpus
|
||||||
// (`tech-step-training-data.ts`) and the real seeded `TechStep`
|
// (`services/tech-step-intent-service`'s `training_data.py`) and the
|
||||||
// catalog, rather than synthetic injectable fixtures the old
|
// real seeded `TechStep` catalog, rather than synthetic injectable
|
||||||
// regex-based `matchTechStepSpans(description, mappings)` allowed.
|
// fixtures the old regex-based `matchTechStepSpans(description,
|
||||||
// Training + node-nlp's own one-time per-language setup can take a
|
// mappings)` allowed. Every call round-trips over HTTP to a real,
|
||||||
// few seconds on the very first call in the whole suite (subsequent
|
// locally running `services/tech-step-intent-service` (see that
|
||||||
// calls reuse the same trained model and are fast) — comfortably
|
// service's own README and `apps/api/.env.test`) — that service trains
|
||||||
// inside this suite's default 10s timeout (.mocharc.json).
|
// 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 simmerId: number;
|
||||||
let cookId: number;
|
let cookId: number;
|
||||||
let bakeId: number;
|
let bakeId: number;
|
||||||
|
|
@ -132,18 +135,36 @@ describe("tech-step-matcher", () => {
|
||||||
let meltId: number;
|
let meltId: number;
|
||||||
let boilId: number;
|
let boilId: number;
|
||||||
let chopId: 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 () => {
|
beforeEach(async () => {
|
||||||
await resetDatabase();
|
await resetDatabase();
|
||||||
const [simmer, cook, bake, preheat, melt, boil, chop] = await Promise.all([
|
const [simmer, cook, bake, preheat, melt, boil, chop, pan, butter, onion, walnuts] =
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } }),
|
await Promise.all([
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "cook" } }),
|
prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } }),
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "bake" } }),
|
prisma.techStep.findFirstOrThrow({ where: { key: "cook" } }),
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "preheat" } }),
|
prisma.techStep.findFirstOrThrow({ where: { key: "bake" } }),
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "melt" } }),
|
prisma.techStep.findFirstOrThrow({ where: { key: "preheat" } }),
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "boil" } }),
|
prisma.techStep.findFirstOrThrow({ where: { key: "melt" } }),
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "chop" } }),
|
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;
|
simmerId = simmer.id;
|
||||||
cookId = cook.id;
|
cookId = cook.id;
|
||||||
bakeId = bake.id;
|
bakeId = bake.id;
|
||||||
|
|
@ -151,6 +172,10 @@ describe("tech-step-matcher", () => {
|
||||||
meltId = melt.id;
|
meltId = melt.id;
|
||||||
boilId = boil.id;
|
boilId = boil.id;
|
||||||
chopId = chop.id;
|
chopId = chop.id;
|
||||||
|
panId = pan.id;
|
||||||
|
butterId = butter.id;
|
||||||
|
onionId = onion.id;
|
||||||
|
walnutsId = walnuts.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
|
|
@ -252,7 +277,15 @@ describe("tech-step-matcher", () => {
|
||||||
const text = "Faire mijoter à feu doux";
|
const text = "Faire mijoter à feu doux";
|
||||||
const result = await techStepClassifier.matchTechStepSpans(text, "fr");
|
const result = await techStepClassifier.matchTechStepSpans(text, "fr");
|
||||||
expect(result).to.deep.equal([
|
expect(result).to.deep.equal([
|
||||||
{ techStepId: simmerId, start: 6, end: 13, contextStart: 0, contextEnd: text.length },
|
{
|
||||||
|
techStepId: simmerId,
|
||||||
|
start: 6,
|
||||||
|
end: 13,
|
||||||
|
contextStart: 0,
|
||||||
|
contextEnd: text.length,
|
||||||
|
ingredients: [],
|
||||||
|
utensils: [],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(text.slice(6, 13).toLowerCase()).to.equal("mijoter");
|
expect(text.slice(6, 13).toLowerCase()).to.equal("mijoter");
|
||||||
});
|
});
|
||||||
|
|
@ -302,6 +335,12 @@ describe("tech-step-matcher", () => {
|
||||||
end: 21,
|
end: 21,
|
||||||
contextStart: 0,
|
contextStart: 0,
|
||||||
contextEnd: 22,
|
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({
|
expect(result[1]).to.deep.equal({
|
||||||
techStepId: meltId,
|
techStepId: meltId,
|
||||||
|
|
@ -309,6 +348,15 @@ describe("tech-step-matcher", () => {
|
||||||
end: 37,
|
end: 37,
|
||||||
contextStart: 22,
|
contextStart: 22,
|
||||||
contextEnd: text.length,
|
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].start, result[0].end)).to.equal("poêle chaude");
|
||||||
expect(text.slice(result[0].contextStart, result[0].contextEnd)).to.equal(
|
expect(text.slice(result[0].contextStart, result[0].contextEnd)).to.equal(
|
||||||
|
|
@ -330,6 +378,15 @@ describe("tech-step-matcher", () => {
|
||||||
end: text.length,
|
end: text.length,
|
||||||
contextStart: 0,
|
contextStart: 0,
|
||||||
contextEnd: text.length,
|
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 }],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
@ -338,10 +395,33 @@ describe("tech-step-matcher", () => {
|
||||||
const text = "Chop the onions finely";
|
const text = "Chop the onions finely";
|
||||||
const result = await techStepClassifier.matchTechStepSpans(text, "en");
|
const result = await techStepClassifier.matchTechStepSpans(text, "en");
|
||||||
expect(result).to.deep.equal([
|
expect(result).to.deep.equal([
|
||||||
{ techStepId: chopId, start: 0, end: 4, contextStart: 0, contextEnd: text.length },
|
{
|
||||||
|
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");
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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,7 +3,7 @@ import request from "supertest";
|
||||||
import { createApp } from "../src/app.js";
|
import { createApp } from "../src/app.js";
|
||||||
import { prisma } from "../src/db/prisma.js";
|
import { prisma } from "../src/db/prisma.js";
|
||||||
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
|
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
|
||||||
import { seedReferenceData } from "../src/db/reference-seed-data.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 type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
|
||||||
import {
|
import {
|
||||||
clearRecipeSources,
|
clearRecipeSources,
|
||||||
|
|
@ -137,7 +137,9 @@ describe("Reference data", () => {
|
||||||
const res = await request(app).get("/reference/tech-steps");
|
const res = await request(app).get("/reference/tech-steps");
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
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.map((t: { key: string }) => t.key)).to.include("simmer");
|
||||||
expect(res.body[0]).to.have.keys(["id", "key"]);
|
expect(res.body[0]).to.have.keys(["id", "key"]);
|
||||||
});
|
});
|
||||||
|
|
@ -155,7 +157,32 @@ describe("Reference data", () => {
|
||||||
await seedReferenceData(prisma);
|
await seedReferenceData(prisma);
|
||||||
|
|
||||||
const res = await request(app).get("/reference/tech-steps");
|
const res = await request(app).get("/reference/tech-steps");
|
||||||
expect(res.body).to.have.length(26);
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -25,6 +25,33 @@ describe("registerAllRecipeSources", () => {
|
||||||
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("theMealDb");
|
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", () => {
|
it("does not register the generic JSON-LD adapter — it's not a household-toggleable source in its own right", () => {
|
||||||
registerAllRecipeSources();
|
registerAllRecipeSources();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,55 @@ function buildDuplicateIngredientAdapter(key = "duplicateFakeSource"): RecipeSou
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal French-content fake adapter — same shape as {@link buildFakeAdapter},
|
||||||
|
* `locale: "fr"` instead of `"en"`. Exercises `previewSourceItem` actually
|
||||||
|
* resolving ingredients for a non-English source through the real HTTP
|
||||||
|
* endpoint/catalog: `loadIngredientCatalog`/`loadUnitCatalog` used to be
|
||||||
|
* called only for `locale === "en"`, silently leaving every ingredient
|
||||||
|
* unresolved for a French source like Marmiton/750g/Manger Bouger — the
|
||||||
|
* regression this test guards against.
|
||||||
|
*/
|
||||||
|
function buildFrenchFakeAdapter(key = "fakeFrSource"): RecipeSourceAdapter<{ externalId: string }> {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
name: "Fake French Source",
|
||||||
|
official: true,
|
||||||
|
iconUrl: null,
|
||||||
|
locale: "fr",
|
||||||
|
async list(_params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
||||||
|
return {
|
||||||
|
items: [
|
||||||
|
{ externalId: "1", title: "Soupe à l'oignon", picture: null, url: "https://fake.test/1" },
|
||||||
|
],
|
||||||
|
nextCursor: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async fetchDetail(externalId: string): Promise<{ externalId: string }> {
|
||||||
|
return { externalId };
|
||||||
|
},
|
||||||
|
parse(raw: { externalId: string }): ParsedRecipe {
|
||||||
|
return {
|
||||||
|
name: `Recette factice ${raw.externalId}`,
|
||||||
|
description: null,
|
||||||
|
picture: null,
|
||||||
|
portions: 4,
|
||||||
|
sourceUrl: `https://fake.test/${raw.externalId}`,
|
||||||
|
ingredients: [
|
||||||
|
{ rawText: "3 carottes", quantity: null, unit: null, name: "carottes" },
|
||||||
|
{
|
||||||
|
rawText: "un ingrédient mystère",
|
||||||
|
quantity: null,
|
||||||
|
unit: null,
|
||||||
|
name: "ingrédient mystère",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
steps: [{ description: "Faire mijoter à feu doux", picture: null }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as `recipe.test.ts`'s own helper. */
|
/** 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> {
|
async function ingredientId(key: string): Promise<number> {
|
||||||
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
|
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
|
||||||
|
|
@ -305,6 +354,37 @@ describe("Sources", () => {
|
||||||
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
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 () => {
|
it("merges two lines that resolve to the same ingredient, summing their quantity (issue #53 follow-up)", async () => {
|
||||||
const { agent } = await signupWithHouse();
|
const { agent } = await signupWithHouse();
|
||||||
registerRecipeSource(buildDuplicateIngredientAdapter());
|
registerRecipeSource(buildDuplicateIngredientAdapter());
|
||||||
|
|
|
||||||
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,18 +6,20 @@ import { splitDescriptionByTechSteps } from "../../src/features/recipes/steps/hi
|
||||||
// `expect`, not for rendering. `.cy.tsx` (not `.cy.ts`) only because that's
|
// `expect`, not for rendering. `.cy.tsx` (not `.cy.ts`) only because that's
|
||||||
// what `cypress.config.ts`'s component `specPattern` looks for.
|
// what `cypress.config.ts`'s component `specPattern` looks for.
|
||||||
|
|
||||||
/** Builds a `StepTechStepView` — `context` omitted entirely (not just undefined) when absent, matching what the API actually sends for an older, not-yet-recomputed match (see `StepTechStepView`'s own doc comment). */
|
/** 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(
|
function techStep(
|
||||||
key: string,
|
key: string,
|
||||||
id: number,
|
id: number,
|
||||||
start: number,
|
start: number,
|
||||||
end: number,
|
end: number,
|
||||||
context?: { start: number; end: number },
|
context?: { start: number; end: number },
|
||||||
|
source: StepTechStepView["source"] = "auto",
|
||||||
): StepTechStepView {
|
): StepTechStepView {
|
||||||
return {
|
return {
|
||||||
techStep: { id, key },
|
techStep: { id, key },
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
|
source,
|
||||||
...(context ? { contextStart: context.start, contextEnd: context.end } : {}),
|
...(context ? { contextStart: context.start, contextEnd: context.end } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -25,7 +27,7 @@ function techStep(
|
||||||
describe("splitDescriptionByTechSteps", () => {
|
describe("splitDescriptionByTechSteps", () => {
|
||||||
it("returns the whole description as one plain segment when there are no matches", () => {
|
it("returns the whole description as one plain segment when there are no matches", () => {
|
||||||
expect(splitDescriptionByTechSteps("Servir immédiatement", [])).to.deep.equal([
|
expect(splitDescriptionByTechSteps("Servir immédiatement", [])).to.deep.equal([
|
||||||
{ text: "Servir immédiatement", techStep: null, isKeyword: false },
|
{ text: "Servir immédiatement", techStep: null, isKeyword: false, source: null },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -37,25 +39,25 @@ describe("splitDescriptionByTechSteps", () => {
|
||||||
techStep("simmer", 1, 6, 13),
|
techStep("simmer", 1, 6, 13),
|
||||||
]);
|
]);
|
||||||
expect(result).to.deep.equal([
|
expect(result).to.deep.equal([
|
||||||
{ text: "Faire ", techStep: null, isKeyword: false },
|
{ text: "Faire ", techStep: null, isKeyword: false, source: null },
|
||||||
{ text: "mijoter", techStep: { id: 1, key: "simmer" }, isKeyword: true },
|
{ text: "mijoter", techStep: { id: 1, key: "simmer" }, isKeyword: true, source: "auto" },
|
||||||
{ text: " à feu doux", techStep: null, isKeyword: false },
|
{ text: " à feu doux", techStep: null, isKeyword: false, source: null },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles a match at the very start, with nothing before it", () => {
|
it("handles a match at the very start, with nothing before it", () => {
|
||||||
const result = splitDescriptionByTechSteps("Hacher les oignons", [techStep("chop", 2, 0, 6)]);
|
const result = splitDescriptionByTechSteps("Hacher les oignons", [techStep("chop", 2, 0, 6)]);
|
||||||
expect(result).to.deep.equal([
|
expect(result).to.deep.equal([
|
||||||
{ text: "Hacher", techStep: { id: 2, key: "chop" }, isKeyword: true },
|
{ text: "Hacher", techStep: { id: 2, key: "chop" }, isKeyword: true, source: "auto" },
|
||||||
{ text: " les oignons", techStep: null, isKeyword: false },
|
{ text: " les oignons", techStep: null, isKeyword: false, source: null },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles a match at the very end, with nothing after it", () => {
|
it("handles a match at the very end, with nothing after it", () => {
|
||||||
const result = splitDescriptionByTechSteps("Faire cuire", [techStep("cook", 3, 6, 11)]);
|
const result = splitDescriptionByTechSteps("Faire cuire", [techStep("cook", 3, 6, 11)]);
|
||||||
expect(result).to.deep.equal([
|
expect(result).to.deep.equal([
|
||||||
{ text: "Faire ", techStep: null, isKeyword: false },
|
{ text: "Faire ", techStep: null, isKeyword: false, source: null },
|
||||||
{ text: "cuire", techStep: { id: 3, key: "cook" }, isKeyword: true },
|
{ text: "cuire", techStep: { id: 3, key: "cook" }, isKeyword: true, source: "auto" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -71,6 +73,7 @@ describe("splitDescriptionByTechSteps", () => {
|
||||||
text: "Préchauffer",
|
text: "Préchauffer",
|
||||||
techStep: { id: 4, key: "preheat" },
|
techStep: { id: 4, key: "preheat" },
|
||||||
isKeyword: true,
|
isKeyword: true,
|
||||||
|
source: "auto",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -88,17 +91,23 @@ describe("splitDescriptionByTechSteps", () => {
|
||||||
|
|
||||||
it("drops a match whose end is past the end of the description", () => {
|
it("drops a match whose end is past the end of the description", () => {
|
||||||
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 0, 999)]);
|
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 0, 999)]);
|
||||||
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]);
|
expect(result).to.deep.equal([
|
||||||
|
{ text: "Cuire", techStep: null, isKeyword: false, source: null },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops a match with a negative start", () => {
|
it("drops a match with a negative start", () => {
|
||||||
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, -1, 5)]);
|
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, -1, 5)]);
|
||||||
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]);
|
expect(result).to.deep.equal([
|
||||||
|
{ text: "Cuire", techStep: null, isKeyword: false, source: null },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops a match whose start isn't before its end", () => {
|
it("drops a match whose start isn't before its end", () => {
|
||||||
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 3, 3)]);
|
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 3, 3)]);
|
||||||
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]);
|
expect(result).to.deep.equal([
|
||||||
|
{ text: "Cuire", techStep: null, isKeyword: false, source: null },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops a later match that overlaps one already accepted", () => {
|
it("drops a later match that overlaps one already accepted", () => {
|
||||||
|
|
@ -110,7 +119,7 @@ describe("splitDescriptionByTechSteps", () => {
|
||||||
techStep("cook", 2, 0, 5),
|
techStep("cook", 2, 0, 5),
|
||||||
]);
|
]);
|
||||||
expect(result).to.deep.equal([
|
expect(result).to.deep.equal([
|
||||||
{ text: "Cuire au four", techStep: { id: 3, key: "bake" }, isKeyword: true },
|
{ text: "Cuire au four", techStep: { id: 3, key: "bake" }, isKeyword: true, source: "auto" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -118,6 +127,42 @@ describe("splitDescriptionByTechSteps", () => {
|
||||||
expect(splitDescriptionByTechSteps("", [])).to.deep.equal([]);
|
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", () => {
|
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", () => {
|
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
|
// The motivating example: "Dans une poêle chaude, faire chauffer une
|
||||||
|
|
@ -128,12 +173,23 @@ describe("splitDescriptionByTechSteps", () => {
|
||||||
techStep("preheat", 4, 9, 21, { start: 0, end: 21 }),
|
techStep("preheat", 4, 9, 21, { start: 0, end: 21 }),
|
||||||
]);
|
]);
|
||||||
expect(result).to.deep.equal([
|
expect(result).to.deep.equal([
|
||||||
{ text: "Dans une ", techStep: { id: 4, key: "preheat" }, isKeyword: false },
|
{
|
||||||
{ text: "poêle chaude", techStep: { id: 4, key: "preheat" }, isKeyword: true },
|
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",
|
text: ", faire chauffer une noix de beurre",
|
||||||
techStep: null,
|
techStep: null,
|
||||||
isKeyword: false,
|
isKeyword: false,
|
||||||
|
source: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
@ -143,8 +199,13 @@ describe("splitDescriptionByTechSteps", () => {
|
||||||
techStep("preheat", 4, 0, 11, { start: 0, end: 19 }),
|
techStep("preheat", 4, 0, 11, { start: 0, end: 19 }),
|
||||||
]);
|
]);
|
||||||
expect(result).to.deep.equal([
|
expect(result).to.deep.equal([
|
||||||
{ text: "préchauffer", techStep: { id: 4, key: "preheat" }, isKeyword: true },
|
{
|
||||||
{ text: " le four", techStep: { id: 4, key: "preheat" }, isKeyword: false },
|
text: "préchauffer",
|
||||||
|
techStep: { id: 4, key: "preheat" },
|
||||||
|
isKeyword: true,
|
||||||
|
source: "auto",
|
||||||
|
},
|
||||||
|
{ text: " le four", techStep: { id: 4, key: "preheat" }, isKeyword: false, source: "auto" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -153,9 +214,19 @@ describe("splitDescriptionByTechSteps", () => {
|
||||||
techStep("preheat", 4, 17, 28, { start: 7, end: 28 }),
|
techStep("preheat", 4, 17, 28, { start: 7, end: 28 }),
|
||||||
]);
|
]);
|
||||||
expect(result).to.deep.equal([
|
expect(result).to.deep.equal([
|
||||||
{ text: "mettre ", techStep: null, isKeyword: false },
|
{ text: "mettre ", techStep: null, isKeyword: false, source: null },
|
||||||
{ text: "le four à ", techStep: { id: 4, key: "preheat" }, isKeyword: false },
|
{
|
||||||
{ text: "préchauffer", techStep: { id: 4, key: "preheat" }, isKeyword: true },
|
text: "le four à ",
|
||||||
|
techStep: { id: 4, key: "preheat" },
|
||||||
|
isKeyword: false,
|
||||||
|
source: "auto",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "préchauffer",
|
||||||
|
techStep: { id: 4, key: "preheat" },
|
||||||
|
isKeyword: true,
|
||||||
|
source: "auto",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -171,7 +242,9 @@ describe("splitDescriptionByTechSteps", () => {
|
||||||
// contextEnd (5) is before the keyword's own end (13) — malformed.
|
// contextEnd (5) is before the keyword's own end (13) — malformed.
|
||||||
techStep("bake", 3, 0, 13, { start: 0, end: 5 }),
|
techStep("bake", 3, 0, 13, { start: 0, end: 5 }),
|
||||||
]);
|
]);
|
||||||
expect(result).to.deep.equal([{ text: "Cuire au four", techStep: null, isKeyword: false }]);
|
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.
|
// that cap was dropped so they fill the width like every other page.
|
||||||
cy.visit("/parametres/compte");
|
cy.visit("/parametres/compte");
|
||||||
assertFillsContentWidth(".settings-page");
|
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");
|
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. */
|
/** 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", () => {
|
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"
|
// 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.
|
// 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", "Planning").should("have.class", "active");
|
||||||
|
|
||||||
cy.contains("nav a", "Recettes").click();
|
cy.contains("nav a", "Recettes").click();
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,29 @@ Feature: Browsing external recipe sources
|
||||||
And the recipe detail panel heading should be "Fish Pie"
|
And the recipe detail panel heading should be "Fish Pie"
|
||||||
And I should see the highlighted technique "Cuire"
|
And I should see the highlighted technique "Cuire"
|
||||||
|
|
||||||
|
Scenario: Loads further pages automatically, with no "load more" button
|
||||||
|
Given the recipe catalog contains nothing
|
||||||
|
And the sources reference list has options
|
||||||
|
And the household has enabled TheMealDB
|
||||||
|
And browsing TheMealDB returns two pages of items
|
||||||
|
When I visit "/recettes"
|
||||||
|
And I click the button "TheMealDB"
|
||||||
|
Then I should see the source item "Chicken Handi"
|
||||||
|
And I should see the source item "Beef Wellington"
|
||||||
|
And I should not see "Voir plus"
|
||||||
|
|
||||||
|
Scenario: Offers a retry when loading the next page fails
|
||||||
|
Given the recipe catalog contains nothing
|
||||||
|
And the sources reference list has options
|
||||||
|
And the household has enabled TheMealDB
|
||||||
|
And browsing TheMealDB's next page fails once, then succeeds
|
||||||
|
When I visit "/recettes"
|
||||||
|
And I click the button "TheMealDB"
|
||||||
|
Then I should see the source item "Chicken Handi"
|
||||||
|
And I should see a message to retry loading more
|
||||||
|
When I click the button "Réessayer"
|
||||||
|
Then I should see the source item "Beef Wellington"
|
||||||
|
|
||||||
Scenario: Deep-links straight to a not-yet-imported item's own page, with no import affordance at all
|
Scenario: Deep-links straight to a not-yet-imported item's own page, with no import affordance at all
|
||||||
Given the recipe catalog contains nothing
|
Given the recipe catalog contains nothing
|
||||||
And the sources reference list has options
|
And the sources reference list has options
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
Given("previewing TheMealDB item {string} is available", (externalId: string) => {
|
||||||
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
|
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,24 @@ Feature: Managing a recipe from the catalog
|
||||||
When I focus the highlighted technique "Cuire"
|
When I focus the highlighted technique "Cuire"
|
||||||
Then the tooltip should show "Cuire"
|
Then the tooltip should show "Cuire"
|
||||||
|
|
||||||
|
Scenario: Corrects a detected technique from its highlight, visible immediately as a manual match
|
||||||
|
Given the recipe catalog contains "Omelette"
|
||||||
|
And recipe 2's detail is available
|
||||||
|
And the tech steps reference list has options
|
||||||
|
And correcting step 2's "Cuire" match will succeed
|
||||||
|
When I visit "/recettes/2"
|
||||||
|
And I click the highlighted technique "Cuire"
|
||||||
|
Then I should see the technique correction options
|
||||||
|
When I choose "Mijoter" as the correct technique
|
||||||
|
Then the correction request should have been made
|
||||||
|
# The highlighted *word* stays "Cuire" (a relabel changes which
|
||||||
|
# technique a span means, not the literal text at that span, still
|
||||||
|
# "Cuire" in the source description) — now styled as a manual
|
||||||
|
# correction, with its tooltip naming the newly-assigned technique.
|
||||||
|
And the highlighted technique "Cuire" should be marked as a manual correction
|
||||||
|
When I focus the highlighted technique "Cuire"
|
||||||
|
Then the tooltip should show "Mijoter (correction manuelle)"
|
||||||
|
|
||||||
Scenario: Deletes a recipe after a two-step confirmation, then clears the selection
|
Scenario: Deletes a recipe after a two-step confirmation, then clears the selection
|
||||||
Given the recipe catalog contains "Omelette"
|
Given the recipe catalog contains "Omelette"
|
||||||
And recipe 2's detail is available
|
And recipe 2's detail is available
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ const omeletteDetail = {
|
||||||
// "Cuire" -> the `cook` technique, matching real reference-seed-data.ts
|
// "Cuire" -> the `cook` technique, matching real reference-seed-data.ts
|
||||||
// (`\bcui(re|sez|sant|sson)\b`) — "poêle" itself matches nothing
|
// (`\bcui(re|sez|sant|sson)\b`) — "poêle" itself matches nothing
|
||||||
// (that's `panFry`'s "sauter", a different word).
|
// (that's `panFry`'s "sauter", a different word).
|
||||||
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }],
|
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5, source: "auto" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
@ -68,6 +68,84 @@ Given("deleting recipe 2 will succeed", () => {
|
||||||
cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe");
|
cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Step 2 is `omeletteDetail`'s "Cuire à la poêle." step, whose only
|
||||||
|
// existing match is `cook` (id 1) — see that fixture above. The response
|
||||||
|
// mirrors `SubmitTechStepCorrectionResult` (packages/shared): the audit
|
||||||
|
// record (reassigning the match to `simmer`, id 3, "Mijoter" — see `the
|
||||||
|
// tech steps reference list has options`, reference-data.steps.ts) plus
|
||||||
|
// the step's fresh `techSteps`, now showing that same reassignment as a
|
||||||
|
// `"manual"`-sourced entry — the API applies a correction immediately, it
|
||||||
|
// doesn't just record it (see `StepTechStepView.source`'s own doc comment).
|
||||||
|
Given('correcting step 2\'s "Cuire" match will succeed', () => {
|
||||||
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
||||||
|
statusCode: 201,
|
||||||
|
body: {
|
||||||
|
correction: {
|
||||||
|
id: 1,
|
||||||
|
start: 0,
|
||||||
|
end: 5,
|
||||||
|
previousTechStep: { id: 1, key: "cook" },
|
||||||
|
correctedTechStep: { id: 3, key: "simmer" },
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
techSteps: [
|
||||||
|
{
|
||||||
|
techStep: { id: 3, key: "simmer" },
|
||||||
|
start: 0,
|
||||||
|
end: 5,
|
||||||
|
source: "manual",
|
||||||
|
ingredients: [],
|
||||||
|
utensils: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}).as("correction");
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I click the highlighted technique {string}", (text: string) => {
|
||||||
|
cy.contains(".step-tech-step", text).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("I should see the technique correction options", () => {
|
||||||
|
cy.get(".tech-step-correction-popover").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Picking a technique only *selects* it — it takes a separate "Valider"
|
||||||
|
// click to actually submit (room was made for attaching ingredient/utensil
|
||||||
|
// metadata alongside it, see `TechStepCorrectionPopover.tsx`'s own doc
|
||||||
|
// comment on its merged editor) — folded into this one step since nothing
|
||||||
|
// in this scenario cares about that intermediate state on its own. The
|
||||||
|
// technique catalog is picked via the same searchable `CatalogSearchPicker`
|
||||||
|
// the ingredient/utensil sub-flows use, scoped to
|
||||||
|
// `__technique-section` since that same search-and-pick component is
|
||||||
|
// reused inside this popover for more than just techniques.
|
||||||
|
When("I choose {string} as the correct technique", (label: string) => {
|
||||||
|
cy.contains(
|
||||||
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||||
|
label,
|
||||||
|
).click();
|
||||||
|
cy.contains(".tech-step-correction-popover__confirm-button", "Valider").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the highlighted technique {string} should be marked as a manual correction",
|
||||||
|
(text: string) => {
|
||||||
|
cy.contains(".step-tech-step", text).should("have.class", "step-tech-step--manual");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Then("the correction request should have been made", () => {
|
||||||
|
// Asserts the actual span, not just that *a* request fired — a real bug
|
||||||
|
// (StepDescription.tsx's click handler reading a shared, still-mutating
|
||||||
|
// `offset` variable by reference instead of a value captured at render
|
||||||
|
// time) once sent `end` all the way to the end of the description
|
||||||
|
// instead of "Cuire"'s own tight [0, 5) span, and a request-fired-only
|
||||||
|
// assertion here didn't catch it — found only via manual testing.
|
||||||
|
cy.wait("@correction")
|
||||||
|
.its("request.body")
|
||||||
|
.should("deep.include", { start: 0, end: 5, previousTechStepId: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
Then("the recipe {string} should not be visible in the table", (name: string) => {
|
Then("the recipe {string} should not be visible in the table", (name: string) => {
|
||||||
cy.contains(".recipe-table__name", name).should("not.exist");
|
cy.contains(".recipe-table__name", name).should("not.exist");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
32
apps/web/cypress/e2e/shopping-list.feature
Normal file
32
apps/web/cypress/e2e/shopping-list.feature
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
Feature: Shopping list
|
||||||
|
As a signed-in user
|
||||||
|
I want to see every ingredient needed for this week's planned recipes, already summed
|
||||||
|
So that I know what to buy without recomputing it myself
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in as "Alice" "Martin"
|
||||||
|
And today is frozen at "2026-08-17T09:00:00.000Z"
|
||||||
|
|
||||||
|
Scenario: Nothing planned this week shows the empty message, not an error
|
||||||
|
Given the shopping list for "2026-08-17" is empty
|
||||||
|
When I visit "/liste-de-courses"
|
||||||
|
Then I should see "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning."
|
||||||
|
|
||||||
|
Scenario: Ingredients are grouped by aisle, in canonical order, each with its summed quantity
|
||||||
|
Given the shopping list for "2026-08-17" contains:
|
||||||
|
| ingredientKey | icon | category | quantity | unitKey |
|
||||||
|
| egg | EGG | dairyAndCheese | 6 | piece |
|
||||||
|
| tomato | VEGETABLE | freshProduce | 400 | gram |
|
||||||
|
When I visit "/liste-de-courses"
|
||||||
|
Then the shopping list group "Produits frais" should appear before "Crémerie & fromage"
|
||||||
|
And the shopping list should show "Tomate" at quantity "400 g"
|
||||||
|
And the shopping list should show "Oeuf" at quantity "6 unité"
|
||||||
|
|
||||||
|
Scenario: Navigating to another week fetches and shows that week's own list
|
||||||
|
Given the shopping list for "2026-08-17" is empty
|
||||||
|
And the shopping list for "2026-08-24" contains:
|
||||||
|
| ingredientKey | icon | category | quantity | unitKey |
|
||||||
|
| onion | VEGETABLE | freshProduce | 1 | kilogram |
|
||||||
|
When I visit "/liste-de-courses"
|
||||||
|
And I click the next week arrow
|
||||||
|
Then the shopping list should show "Oignon" at quantity "1 kg"
|
||||||
72
apps/web/cypress/e2e/shopping-list.ts
Normal file
72
apps/web/cypress/e2e/shopping-list.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { type DataTable, Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One data-table row → a fake `ShoppingListItemView` — same minimal-fixture
|
||||||
|
* convention as `recipe-form.ts`'s ingredient fixtures (only the fields
|
||||||
|
* `ShoppingListPage` actually reads at runtime: the ingredient's `key`/
|
||||||
|
* `icon`/`category` for `IngredientTypeIcon`/`CategoryIcon`/translation, the
|
||||||
|
* unit's `key`; `id` only needs to be unique per row for the React list
|
||||||
|
* key). `index` seeds both ids so two rows never collide.
|
||||||
|
*/
|
||||||
|
function buildShoppingListItem(
|
||||||
|
row: { ingredientKey: string; icon: string; category: string; quantity: string; unitKey: string },
|
||||||
|
index: number,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ingredient: {
|
||||||
|
id: index,
|
||||||
|
key: row.ingredientKey,
|
||||||
|
icon: row.icon,
|
||||||
|
category: row.category,
|
||||||
|
subcategory: row.category,
|
||||||
|
reproducible: false,
|
||||||
|
allergens: [],
|
||||||
|
diets: [],
|
||||||
|
},
|
||||||
|
quantity: Number(row.quantity),
|
||||||
|
unit: { id: index, key: row.unitKey, type: "MASS", toBaseFactor: 1 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Given("the shopping list for {string} is empty", (date: string) => {
|
||||||
|
cy.intercept("GET", `**/shopping-list?date=${date}`, {
|
||||||
|
statusCode: 200,
|
||||||
|
body: { startDate: date, finishDate: date, items: [] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("the shopping list for {string} contains:", (date: string, dataTable: DataTable) => {
|
||||||
|
const items = dataTable.hashes().map((row, i) => buildShoppingListItem(row, i + 1));
|
||||||
|
cy.intercept("GET", `**/shopping-list?date=${date}`, {
|
||||||
|
statusCode: 200,
|
||||||
|
body: { startDate: date, finishDate: date, items },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same class as PlanningPage's own week navigator (`WeekNavigator`, now
|
||||||
|
// shared between the two pages) — `planning-page.cy.ts` already exercises
|
||||||
|
// the prev/next arrows directly by class, same approach here.
|
||||||
|
When("I click the next week arrow", () => {
|
||||||
|
cy.get(".week-nav__arrow").last().click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the shopping list group {string} should appear before {string}",
|
||||||
|
(first: string, second: string) => {
|
||||||
|
cy.get(".shopping-list__group-title").then(($titles) => {
|
||||||
|
const texts = [...$titles].map((el) => el.textContent?.trim() ?? "");
|
||||||
|
const firstIndex = texts.findIndex((text) => text.includes(first));
|
||||||
|
const secondIndex = texts.findIndex((text) => text.includes(second));
|
||||||
|
expect(firstIndex, `"${first}" should be a rendered group`).to.be.greaterThan(-1);
|
||||||
|
expect(secondIndex, `"${second}" should be a rendered group`).to.be.greaterThan(-1);
|
||||||
|
expect(firstIndex).to.be.lessThan(secondIndex);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the shopping list should show {string} at quantity {string}",
|
||||||
|
(name: string, quantity: string) => {
|
||||||
|
cy.contains(".shopping-list__item", name).should("contain.text", quantity);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -40,6 +40,21 @@ Given("the sources reference list is empty", () => {
|
||||||
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
|
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `id`/`key` pairs mirror `recipes.ts`'s `omeletteDetail` fixture (`cook`,
|
||||||
|
// id 1, is the step's existing match) plus a second option
|
||||||
|
// (`simmer`/"Mijoter") for `recipes.feature`'s correction scenario to
|
||||||
|
// re-assign to — TechStepCorrectionPopover's own picker needs at least two
|
||||||
|
// choices for that scenario to be a meaningful correction, not a no-op.
|
||||||
|
Given("the tech steps reference list has options", () => {
|
||||||
|
cy.intercept("GET", "**/reference/tech-steps", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: [
|
||||||
|
{ id: 1, key: "cook" },
|
||||||
|
{ id: 3, key: "simmer" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// The real first entry (`theMealDb`) mirrors what's actually seeded
|
// The real first entry (`theMealDb`) mirrors what's actually seeded
|
||||||
// (`reference-seed-data.ts`'s `registerAllRecipeSources`/
|
// (`reference-seed-data.ts`'s `registerAllRecipeSources`/
|
||||||
// `syncRecipeSources`); the second is illustrative only — a future
|
// `syncRecipeSources`); the second is illustrative only — a future
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,17 @@ import {
|
||||||
type RecipeTab,
|
type RecipeTab,
|
||||||
type RecipeView,
|
type RecipeView,
|
||||||
type SafeUserProfile,
|
type SafeUserProfile,
|
||||||
|
type ShoppingListView,
|
||||||
type SignupInput,
|
type SignupInput,
|
||||||
type SourceView,
|
type SourceView,
|
||||||
|
type StepTechStepCorrectionView,
|
||||||
|
type SubmitTechStepCorrectionInput,
|
||||||
|
type SubmitTechStepCorrectionResult,
|
||||||
|
type TechStepView,
|
||||||
type ThemePreference,
|
type ThemePreference,
|
||||||
type UnitView,
|
type UnitView,
|
||||||
type UpdateRecipeInput,
|
type UpdateRecipeInput,
|
||||||
|
type UtensilView,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -159,6 +165,18 @@ export class ApiClient {
|
||||||
return this._request(`/planning/items/${id}`, { method: "DELETE" });
|
return this._request(`/planning/items/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the current user's household's shopping list for the week
|
||||||
|
* covering `date` (`YYYY-MM-DD`, e.g. from `date-tools`'s
|
||||||
|
* `formatDateOnly`) — every ingredient across that week's planned
|
||||||
|
* recipes, summed. Unlike {@link getPlanningForWeek}, never resolves to
|
||||||
|
* `null`: no household or nothing planned that week both come back as a
|
||||||
|
* normal list with an empty `items` array.
|
||||||
|
*/
|
||||||
|
public getShoppingListForWeek(date: string): Promise<ShoppingListView> {
|
||||||
|
return this._request(`/shopping-list?date=${date}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
|
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
|
||||||
public getDiets(): Promise<DietView[]> {
|
public getDiets(): Promise<DietView[]> {
|
||||||
return this._request("/reference/diets");
|
return this._request("/reference/diets");
|
||||||
|
|
@ -179,6 +197,16 @@ export class ApiClient {
|
||||||
return this._request("/reference/units");
|
return this._request("/reference/units");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reference list of detected cooking techniques — static, non-administrable (`TechStepCorrectionPopover`'s technique picker). Public — no session required. */
|
||||||
|
public getTechSteps(): Promise<TechStepView[]> {
|
||||||
|
return this._request("/reference/tech-steps");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reference list of cooking utensils — static, non-administrable (`TechStepCorrectionPopover`'s utensil picker, once a technique is selected). Public — no session required. */
|
||||||
|
public getUtensils(): Promise<UtensilView[]> {
|
||||||
|
return this._request("/reference/utensils");
|
||||||
|
}
|
||||||
|
|
||||||
/** Reference list of implemented recipe sources (onboarding wizard's source step, `/parametres/foyer`) — empty until a concrete source is registered. Public — no session required. */
|
/** Reference list of implemented recipe sources (onboarding wizard's source step, `/parametres/foyer`) — empty until a concrete source is registered. Public — no session required. */
|
||||||
public getSources(): Promise<SourceView[]> {
|
public getSources(): Promise<SourceView[]> {
|
||||||
return this._request("/reference/sources");
|
return this._request("/reference/sources");
|
||||||
|
|
@ -267,6 +295,26 @@ export class ApiClient {
|
||||||
return this._request(`/recipes/${id}/favorite`, { method: "DELETE" });
|
return this._request(`/recipes/${id}/favorite`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Submits a correction to one of `stepId`'s detected techniques — see `SubmitTechStepCorrectionInput`'s doc comment (`packages/shared`) for what `previousTechStepId`/`correctedTechStepId` each mean. Open to any viewer who can see the recipe, not just its author. The response's `techSteps` is the step's fresh, immediately up-to-date technique sequence — see `SubmitTechStepCorrectionResult`'s doc comment. */
|
||||||
|
public submitTechStepCorrection(
|
||||||
|
recipeId: number,
|
||||||
|
stepId: number,
|
||||||
|
input: SubmitTechStepCorrectionInput,
|
||||||
|
): Promise<SubmitTechStepCorrectionResult> {
|
||||||
|
return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every correction submitted so far for `stepId`, most recent first. */
|
||||||
|
public getTechStepCorrections(
|
||||||
|
recipeId: number,
|
||||||
|
stepId: number,
|
||||||
|
): Promise<StepTechStepCorrectionView[]> {
|
||||||
|
return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */
|
/** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */
|
||||||
public getCurrentHouse(): Promise<HouseView | null> {
|
public getCurrentHouse(): Promise<HouseView | null> {
|
||||||
return this._request("/house/current");
|
return this._request("/house/current");
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
// =============================================================================
|
|
||||||
// Styles for ComingSoonPage — shared by every stub section page.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
// Centered, not pinned to `.app-content`'s left edge — same reasoning as
|
|
||||||
// `.settings-page` (settings-pages.scss): on a wide desktop viewport a
|
|
||||||
// left-aligned `max-width` here just left a lopsided gap down the right
|
|
||||||
// side instead of framing the placeholder copy.
|
|
||||||
.coming-soon-page {
|
|
||||||
max-width: 40rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import "./ComingSoonPage.scss";
|
|
||||||
|
|
||||||
interface ComingSoonPageProps {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder rendered by a section that has a route/sidebar entry but no
|
|
||||||
* real feature behind it yet — today only `pages/shopping-list/ShoppingListPage.tsx`
|
|
||||||
* (`Recettes`/`Foyer & profil` both grew real backends since this was
|
|
||||||
* written, see `pages/recipes/`/`pages/settings/`). Kept as a shared,
|
|
||||||
* reusable component (`components/ui/`, not itself a routed page) rather
|
|
||||||
* than inlined into that one page, so a future stub section doesn't need to
|
|
||||||
* hand-roll the same markup — the page that needs it still gets its own
|
|
||||||
* file (and its own copy, via i18n), just wrapping this instead of
|
|
||||||
* rewriting it.
|
|
||||||
*/
|
|
||||||
export function ComingSoonPage({ title, description }: ComingSoonPageProps) {
|
|
||||||
return (
|
|
||||||
<div className="coming-soon-page">
|
|
||||||
<h1>{title}</h1>
|
|
||||||
<p>{description}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
174
apps/web/src/features/planning/WeekNavigator.tsx
Normal file
174
apps/web/src/features/planning/WeekNavigator.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import {
|
||||||
|
addWeeks,
|
||||||
|
buildCalendarMonth,
|
||||||
|
DateTime,
|
||||||
|
getWeekStart,
|
||||||
|
toDateOnly,
|
||||||
|
} from "@batch-cooking/date-tools";
|
||||||
|
import { WEEK_DAYS } from "@batch-cooking/shared";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import "./week-navigator.scss";
|
||||||
|
|
||||||
|
/** "17 au 23 août 2026" — collapses the month/year to just the end date when both ends of the week share it, spells it out on both ends otherwise (e.g. a week straddling two months). */
|
||||||
|
function formatWeekRange(weekStart: DateTime): string {
|
||||||
|
const weekEnd = weekStart.plus({ days: 6 });
|
||||||
|
const sameMonth = weekStart.hasSame(weekEnd, "month");
|
||||||
|
const startLabel = weekStart.toLocaleString(
|
||||||
|
sameMonth ? { day: "numeric" } : { day: "numeric", month: "long" },
|
||||||
|
{ locale: "fr" },
|
||||||
|
);
|
||||||
|
const endLabel = weekEnd.toLocaleString(
|
||||||
|
{ day: "numeric", month: "long", year: "numeric" },
|
||||||
|
{ locale: "fr" },
|
||||||
|
);
|
||||||
|
return `${startLabel} au ${endLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrows + clickable label opening {@link CalendarPopover} — week-selection
|
||||||
|
* UI shared by any page organized around "one week at a time" (originally
|
||||||
|
* `PlanningPage`'s own grid, now also `ShoppingListPage` — both just need a
|
||||||
|
* `weekStart` in/out, neither cares how the other renders its own content
|
||||||
|
* for that week). Copy comes from `common.weekNav.*`/`common.calendar.*`/
|
||||||
|
* `common.days.*` rather than `planning.*` — generic enough ("Semaine
|
||||||
|
* précédente", day names) to not read as planning-specific from a page that
|
||||||
|
* isn't the planning grid.
|
||||||
|
*/
|
||||||
|
export function WeekNavigator({
|
||||||
|
weekStart,
|
||||||
|
onChangeWeek,
|
||||||
|
}: {
|
||||||
|
weekStart: DateTime;
|
||||||
|
onChangeWeek: (weekStart: DateTime) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||||||
|
const isThisWeek = weekStart.hasSame(getWeekStart(DateTime.utc()), "day");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="week-nav">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="week-nav__arrow"
|
||||||
|
title={t("common.weekNav.prevWeek")}
|
||||||
|
onClick={() => onChangeWeek(addWeeks(weekStart, -1))}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="week-nav__label"
|
||||||
|
onClick={() => setIsCalendarOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
📅 {t("common.weekNav.label", { range: formatWeekRange(weekStart) })}
|
||||||
|
{isThisWeek && <span className="today-badge">{t("common.weekNav.thisWeek")}</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="week-nav__arrow"
|
||||||
|
title={t("common.weekNav.nextWeek")}
|
||||||
|
onClick={() => onChangeWeek(addWeeks(weekStart, 1))}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isCalendarOpen && (
|
||||||
|
<CalendarPopover
|
||||||
|
selectedWeekStart={weekStart}
|
||||||
|
onSelectDay={(day) => {
|
||||||
|
onChangeWeek(getWeekStart(day));
|
||||||
|
setIsCalendarOpen(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setIsCalendarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Month calendar letting the visitor jump to any week at once — selecting a day selects its whole (Monday-first) week. Closes itself on an outside click. */
|
||||||
|
function CalendarPopover({
|
||||||
|
selectedWeekStart,
|
||||||
|
onSelectDay,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
selectedWeekStart: DateTime;
|
||||||
|
onSelectDay: (day: DateTime) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Its own state: browsing to a different month to pick a week there
|
||||||
|
// shouldn't jump back every render — only re-anchors when the popover is
|
||||||
|
// first opened (`selectedWeekStart` at that point), not while it's open.
|
||||||
|
const [visibleMonth, setVisibleMonth] = useState(() => selectedWeekStart.startOf("month"));
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const today = toDateOnly(DateTime.utc());
|
||||||
|
const selectedWeekEnd = selectedWeekStart.plus({ days: 6 });
|
||||||
|
const weeks = buildCalendarMonth(visibleMonth);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="calendar-popover" ref={popoverRef}>
|
||||||
|
<div className="calendar-popover__header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t("common.calendar.prevMonth")}
|
||||||
|
onClick={() => setVisibleMonth((month) => month.minus({ months: 1 }))}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
{visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t("common.calendar.nextMonth")}
|
||||||
|
onClick={() => setVisibleMonth((month) => month.plus({ months: 1 }))}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="calendar-grid">
|
||||||
|
{WEEK_DAYS.map((weekDay) => (
|
||||||
|
<span key={weekDay} className="calendar-grid__weekday">
|
||||||
|
{t(`common.days.${weekDay}`).charAt(0)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{weeks.flat().map((day) => {
|
||||||
|
const classNames = ["calendar-grid__day"];
|
||||||
|
if (!day.hasSame(visibleMonth, "month")) classNames.push("calendar-grid__day--muted");
|
||||||
|
if (day >= selectedWeekStart && day <= selectedWeekEnd) {
|
||||||
|
classNames.push("calendar-grid__day--in-selected-week");
|
||||||
|
}
|
||||||
|
if (day.hasSame(today, "day")) classNames.push("calendar-grid__day--today");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day.toISO()}
|
||||||
|
type="button"
|
||||||
|
className={classNames.join(" ")}
|
||||||
|
onClick={() => onSelectDay(day)}
|
||||||
|
>
|
||||||
|
{day.day}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
apps/web/src/features/planning/week-navigator.scss
Normal file
143
apps/web/src/features/planning/week-navigator.scss
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
// =============================================================================
|
||||||
|
// Styles for WeekNavigator.tsx (arrows + label + calendar popover) —
|
||||||
|
// colocated next to the component since nothing else uses these classes.
|
||||||
|
// Extracted from planning-page.scss once ShoppingListPage started reusing
|
||||||
|
// the component — same design tokens, no light/dark duplication needed
|
||||||
|
// (every `var(--color-*)` below already resolves per-theme globally, see
|
||||||
|
// styles/_theme.scss).
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.week-nav {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
|
||||||
|
&__arrow {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: 0.45rem var(--space-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-badge {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Calendar popover -------------------------------------------------------
|
||||||
|
.calendar-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--space-xs));
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
width: 18rem;
|
||||||
|
padding: var(--space-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
text-transform: capitalize;
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
&__weekday {
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
padding-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__day {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--muted {
|
||||||
|
color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--in-selected-week {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--today {
|
||||||
|
box-shadow: inset 0 0 0 2px var(--color-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -205,11 +205,34 @@ export function RecipeDetailPanel({
|
||||||
|
|
||||||
<section className="recipe-detail-panel__section">
|
<section className="recipe-detail-panel__section">
|
||||||
<h3>{t("recipes.stepsTitle")}</h3>
|
<h3>{t("recipes.stepsTitle")}</h3>
|
||||||
|
{/* Discoverability hint for the highlight/correction feature below
|
||||||
|
— nothing about the steps list itself otherwise signals that a
|
||||||
|
highlighted technique or a plain-text selection is interactive.
|
||||||
|
Tied to `showActions`, same reasoning as `StepDescription`'s own
|
||||||
|
`editable` prop right below. */}
|
||||||
|
{showActions && (
|
||||||
|
<p className="recipe-detail-panel__tech-step-hint">
|
||||||
|
{t("recipes.techStepCorrection.discoverabilityHint")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<ol className="recipe-detail-panel__steps">
|
<ol className="recipe-detail-panel__steps">
|
||||||
{recipe.steps.map((step) => (
|
{recipe.steps.map((step) => (
|
||||||
<li key={step.id}>
|
<li key={step.id}>
|
||||||
{step.picture && <img src={step.picture} alt="" />}
|
{step.picture && <img src={step.picture} alt="" />}
|
||||||
<StepDescription description={step.description} techSteps={step.techSteps} />
|
{/* Tied to `showActions` (not unconditionally on): that flag
|
||||||
|
already distinguishes a full recipe view from a lightweight
|
||||||
|
preview (`RecipePickerDialog`'s browsing step, `showActions={false}`)
|
||||||
|
— offering technique corrections in a quick "pick a recipe
|
||||||
|
for planning" preview would be more distracting than
|
||||||
|
useful there, even though the API itself allows it for any
|
||||||
|
viewer who can see the recipe. */}
|
||||||
|
<StepDescription
|
||||||
|
description={step.description}
|
||||||
|
techSteps={step.techSteps}
|
||||||
|
editable={showActions}
|
||||||
|
recipeId={recipe.id}
|
||||||
|
stepId={step.id}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
|
||||||
|
|
@ -372,20 +372,75 @@
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-items-load-more {
|
// Browsing a source scrolls infinitely (SourceItemTable's own sentinel row
|
||||||
flex-shrink: 0;
|
// triggers RecipeSourcesPanel's handleLoadMore) — this is only the inline
|
||||||
align-self: center;
|
// retry action shown alongside recipes.sources.loadMoreError when a page
|
||||||
padding: 0.4rem var(--space-lg);
|
// fetch actually fails, not a persistent "load more" control.
|
||||||
font-family: var(--font-body);
|
.source-items-retry {
|
||||||
font-size: var(--font-size-sm);
|
padding: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: none;
|
||||||
border-radius: var(--radius-pill);
|
text-decoration: underline;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulsing placeholder rows `SourceItemTable` appends below the real items
|
||||||
|
// while `RecipeSourcesPanel`'s "load more" fetch is in flight — with that
|
||||||
|
// panel prefetching the next page ahead of time (as soon as the sentinel
|
||||||
|
// row scrolls near view), this is usually a very brief flash rather than
|
||||||
|
// an actual wait, but it keeps the list
|
||||||
|
// filling in instead of looking like nothing happened either way.
|
||||||
|
@keyframes source-item-skeleton-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invisible marker row `SourceItemTable`'s `IntersectionObserver` watches
|
||||||
|
// to trigger infinite scroll — no padding/border of its own, unlike a real
|
||||||
|
// row, so it doesn't show up as a stray empty stripe at the bottom of the
|
||||||
|
// list.
|
||||||
|
.source-item-table__sentinel td {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item-table__row--skeleton {
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: var(--color-primary);
|
background: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item-table__photo--skeleton,
|
||||||
|
.source-item-table__skeleton-bar {
|
||||||
|
background: var(--color-border);
|
||||||
|
animation: source-item-skeleton-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item-table__skeleton-bar {
|
||||||
|
display: block;
|
||||||
|
width: 60%;
|
||||||
|
height: 0.9rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.source-item-table__photo--skeleton,
|
||||||
|
.source-item-table__skeleton-bar {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -627,12 +682,273 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A `"manual"`-sourced match (a viewer's correction, applied immediately —
|
||||||
|
// see `StepTechStepView.source`) — same shape as `.step-tech-step`, but in
|
||||||
|
// `--color-tag` (Turmeric) instead of `--color-primary` (Basil), so the two
|
||||||
|
// origins are distinguishable at a glance, not just via the tooltip text.
|
||||||
|
.step-tech-step--manual {
|
||||||
|
background: color-mix(in srgb, var(--color-tag) 18%, transparent);
|
||||||
|
text-decoration-color: var(--color-tag);
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
background: color-mix(in srgb, var(--color-tag) 28%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// `.step-tech-step-context` (the wider clause a `.step-tech-step` keyword
|
// `.step-tech-step-context` (the wider clause a `.step-tech-step` keyword
|
||||||
// was found in) used to be highlighted here too, more subtly — turned back
|
// was found in) used to be highlighted here too, more subtly — turned back
|
||||||
// off (see `StepDescription.tsx`'s doc comment): the backend still
|
// off (see `StepDescription.tsx`'s doc comment): the backend still
|
||||||
// computes and persists `contextStart`/`contextEnd`, this file just no
|
// computes and persists `contextStart`/`contextEnd`, this file just no
|
||||||
// longer gives that class any styling to render with.
|
// longer gives that class any styling to render with.
|
||||||
|
|
||||||
|
// Discoverability hint above the steps list (RecipeDetailPanel.tsx) —
|
||||||
|
// muted so it reads as a small aside, not competing with the steps
|
||||||
|
// themselves for attention.
|
||||||
|
.recipe-detail-panel__tech-step-hint {
|
||||||
|
margin: 0 0 var(--space-sm);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tech-step correction (StepDescription.tsx editable mode) ---------------
|
||||||
|
|
||||||
|
// Deliberately *not* `position: absolute` (unlike `.calendar-popover`) — see
|
||||||
|
// `TechStepCorrectionPopover.tsx`'s doc comment for why this renders inline
|
||||||
|
// in the document flow right below the step's own description instead of
|
||||||
|
// floating anchored at the selection.
|
||||||
|
.tech-step-correction-popover {
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
padding: var(--space-md);
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
|
||||||
|
&__selection {
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Technique picker + Ingrédients/Ustensiles render together as one
|
||||||
|
// screen now (see `TechStepCorrectionPopover.tsx`'s own doc comment) —
|
||||||
|
// this section just needs its own small header row, the actual picker
|
||||||
|
// is `.catalog-search-picker` (below), reused as-is from the ingredient/
|
||||||
|
// utensil sub-flows.
|
||||||
|
&__technique-section {
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 var(--space-xs);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__technique-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__remove {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-error);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-error);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cancel {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shown in place of the technique list/metadata sections while
|
||||||
|
// `StepDescription` is waiting on a second text selection (see
|
||||||
|
// `TechStepCorrectionPopover.tsx`'s own doc comment) — same styling
|
||||||
|
// intent as `.recipe-detail-panel__tech-step-hint`, a small muted aside.
|
||||||
|
&__hint {
|
||||||
|
margin: 0 0 var(--space-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__span-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__quantity-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__confirm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Aucune technique sélectionnée."/"Technique retenue : X" — no button
|
||||||
|
// here anymore (re-picking happens directly through the search picker
|
||||||
|
// right below, see `TechStepCorrectionPopover.tsx`'s doc comment on the
|
||||||
|
// merged editor), just a small status line.
|
||||||
|
&__chosen-technique {
|
||||||
|
margin: 0 0 var(--space-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__metadata-section {
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 var(--space-xs);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "+ Ajouter…" button — deliberately a plain text-link style, not
|
||||||
|
// another pill button (`.catalog-search-picker__list button`) — this
|
||||||
|
// is a secondary action inside an already-open popover, not a
|
||||||
|
// top-level choice competing with the chips above it.
|
||||||
|
> button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 var(--space-xs);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__confirm-button {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-surface);
|
||||||
|
background: var(--color-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reused by both the ingredient and utensil "attach to this correction"
|
||||||
|
// sub-flows (`TechStepCorrectionPopover.tsx`) — deliberately lighter than
|
||||||
|
// `.ingredient-picker` (no category/subcategory grid, no allergen/diet
|
||||||
|
// toggles), sized for a small popover rather than a full recipe form.
|
||||||
|
.catalog-search-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
// Raised from the original 8rem — this component is now also the
|
||||||
|
// technique picker (~74 entries, see this file's own doc comment),
|
||||||
|
// where 8rem left only a couple of rows visible before scrolling.
|
||||||
|
max-height: 14rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The technique picker's current pick (`selectedId` prop) — stays
|
||||||
|
// visually marked even while filtered/scrolled past, so re-opening
|
||||||
|
// this popover's picker doesn't read as "nothing chosen yet" when
|
||||||
|
// something already is. Unused by the ingredient/utensil sub-flows
|
||||||
|
// (they never pass `selectedId` — each pick there just appends a
|
||||||
|
// fresh mention, nothing to mark as "current").
|
||||||
|
&.catalog-search-picker__item--selected {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Favorite star toggle (detail panel header) -----------------------------
|
// --- Favorite star toggle (detail panel header) -----------------------------
|
||||||
.favorite-star-button {
|
.favorite-star-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { BrowsableSourceItemView, RecipeView } from "@batch-cooking/shared";
|
import type { BrowsableSourceItemView, RecipeView } from "@batch-cooking/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { apiClient } from "../../../api/client";
|
import { apiClient } from "../../../api/client";
|
||||||
import { RecipeDetailPanel, type RecipeDetailState } from "../RecipeDetailPanel";
|
import { RecipeDetailPanel, type RecipeDetailState } from "../RecipeDetailPanel";
|
||||||
|
|
@ -9,6 +9,15 @@ import "../recipes.scss";
|
||||||
/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */
|
/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
/** How many pulsing skeleton rows `handleLoadMore` shows while its fetch is in flight — see `loadMoreStatus`. Not tied to any source's real page size (that varies per source, and isn't known client-side); just enough to visibly fill the gap below the list without over-promising. */
|
||||||
|
const LOAD_MORE_PLACEHOLDER_COUNT = 4;
|
||||||
|
|
||||||
|
/** One page of `sourceKey`'s browsable catalog, as returned by `apiClient.browseSource`. */
|
||||||
|
interface BrowsePage {
|
||||||
|
items: BrowsableSourceItemView[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** One item's identity within a source's browsable catalog — `sourceKey` + `externalId` together, since `externalId` alone is only unique per source. */
|
/** One item's identity within a source's browsable catalog — `sourceKey` + `externalId` together, since `externalId` alone is only unique per source. */
|
||||||
export interface SourceItemSelection {
|
export interface SourceItemSelection {
|
||||||
sourceKey: string;
|
sourceKey: string;
|
||||||
|
|
@ -76,6 +85,30 @@ export function RecipeSourcesPanel({
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
|
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
|
||||||
|
const [loadMoreStatus, setLoadMoreStatus] = useState<"idle" | "loading" | "error">("idle");
|
||||||
|
// The *next* page, fetched ahead of time as soon as the current one is on
|
||||||
|
// screen (see the effect below) — a ref, not state, since it's an
|
||||||
|
// implementation detail `handleLoadMore` consumes, never itself rendered.
|
||||||
|
// Keyed on exactly what makes a prefetch valid to reuse (source/query/
|
||||||
|
// cursor all matching) rather than just "is something in flight", so a
|
||||||
|
// stale prefetch from before a search/source change is never mistaken for
|
||||||
|
// the page that's actually needed next.
|
||||||
|
const nextPagePrefetchRef = useRef<{
|
||||||
|
sourceKey: string;
|
||||||
|
query: string;
|
||||||
|
cursor: string;
|
||||||
|
promise: Promise<BrowsePage>;
|
||||||
|
} | null>(null);
|
||||||
|
// Re-entrancy guard for `handleLoadMore` — infinite scroll (unlike a
|
||||||
|
// button `onClick`) can call it again before the previous call has
|
||||||
|
// settled (e.g. the sentinel row is still intersecting when the observer
|
||||||
|
// re-evaluates after a layout shift). A ref, not `loadMoreStatus`: that
|
||||||
|
// state only exists to drive what's rendered and is read from React's
|
||||||
|
// closure at call time, which would still read the *previous* render's
|
||||||
|
// (stale) value inside a handler fired synchronously off a fresh
|
||||||
|
// browser event — this needs to be checked/set immediately and
|
||||||
|
// synchronously, which only a ref does correctly here.
|
||||||
|
const isLoadingMoreRef = useRef(false);
|
||||||
const [selectedExternalId, setSelectedExternalId] = useState<string | null>(
|
const [selectedExternalId, setSelectedExternalId] = useState<string | null>(
|
||||||
initialSelection?.externalId ?? null,
|
initialSelection?.externalId ?? null,
|
||||||
);
|
);
|
||||||
|
|
@ -127,6 +160,11 @@ export function RecipeSourcesPanel({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setBrowseState({ status: "loading" });
|
setBrowseState({ status: "loading" });
|
||||||
|
// A fresh search/source is a fresh list — any in-flight "load more" or
|
||||||
|
// stale prefetch from the *previous* one no longer applies to anything.
|
||||||
|
setLoadMoreStatus("idle");
|
||||||
|
nextPagePrefetchRef.current = null;
|
||||||
|
isLoadingMoreRef.current = false;
|
||||||
|
|
||||||
apiClient
|
apiClient
|
||||||
.browseSource(sourceKey, { query: debouncedSearch.trim() || undefined })
|
.browseSource(sourceKey, { query: debouncedSearch.trim() || undefined })
|
||||||
|
|
@ -142,21 +180,78 @@ export function RecipeSourcesPanel({
|
||||||
};
|
};
|
||||||
}, [sourceKey, debouncedSearch]);
|
}, [sourceKey, debouncedSearch]);
|
||||||
|
|
||||||
|
// Prefetches the page after the one currently on screen, so scrolling
|
||||||
|
// near the bottom (SourceItemTable's sentinel row, which calls
|
||||||
|
// handleLoadMore) usually just swaps in data that's already arrived
|
||||||
|
// instead of starting a fresh round-trip right when someone's waiting on
|
||||||
|
// it — `handleLoadMore` below reuses this when it matches. Re-runs on every
|
||||||
|
// `browseState` change, so a load-more that appends a new page and a new
|
||||||
|
// `nextCursor` immediately kicks off prefetching the page *after* that
|
||||||
|
// one too, keeping the panel permanently one page ahead of what's shown.
|
||||||
|
useEffect(() => {
|
||||||
|
if (browseState.status !== "loaded" || !browseState.nextCursor) return;
|
||||||
|
const cursor = browseState.nextCursor;
|
||||||
|
const query = debouncedSearch.trim();
|
||||||
|
const already = nextPagePrefetchRef.current;
|
||||||
|
if (
|
||||||
|
already &&
|
||||||
|
already.sourceKey === sourceKey &&
|
||||||
|
already.query === query &&
|
||||||
|
already.cursor === cursor
|
||||||
|
) {
|
||||||
|
return; // already prefetching/prefetched exactly this page
|
||||||
|
}
|
||||||
|
const promise = apiClient.browseSource(sourceKey, { query: query || undefined, cursor });
|
||||||
|
nextPagePrefetchRef.current = { sourceKey, query, cursor, promise };
|
||||||
|
// A failed prefetch is swallowed here on purpose — nobody's actually
|
||||||
|
// waiting on it yet. If `handleLoadMore` later reuses this same promise
|
||||||
|
// it awaits/catches the rejection itself at that point; if it's never
|
||||||
|
// reused (the prefetch just goes stale), this `.catch()` only exists to
|
||||||
|
// keep the rejection from surfacing as an unhandled one.
|
||||||
|
promise.catch(() => {});
|
||||||
|
}, [browseState, sourceKey, debouncedSearch]);
|
||||||
|
|
||||||
function handleLoadMore() {
|
function handleLoadMore() {
|
||||||
if (browseState.status !== "loaded" || !browseState.nextCursor) {
|
if (browseState.status !== "loaded" || !browseState.nextCursor) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isLoadingMoreRef.current) {
|
||||||
|
return; // already fetching this exact next page — see the ref's own doc comment
|
||||||
|
}
|
||||||
|
isLoadingMoreRef.current = true;
|
||||||
const cursor = browseState.nextCursor;
|
const cursor = browseState.nextCursor;
|
||||||
apiClient
|
const query = debouncedSearch.trim();
|
||||||
.browseSource(sourceKey, { query: debouncedSearch.trim() || undefined, cursor })
|
setLoadMoreStatus("loading");
|
||||||
|
|
||||||
|
const prefetch = nextPagePrefetchRef.current;
|
||||||
|
const request =
|
||||||
|
prefetch &&
|
||||||
|
prefetch.sourceKey === sourceKey &&
|
||||||
|
prefetch.query === query &&
|
||||||
|
prefetch.cursor === cursor
|
||||||
|
? prefetch.promise
|
||||||
|
: apiClient.browseSource(sourceKey, { query: query || undefined, cursor });
|
||||||
|
|
||||||
|
request
|
||||||
.then(({ items, nextCursor }) => {
|
.then(({ items, nextCursor }) => {
|
||||||
|
nextPagePrefetchRef.current = null;
|
||||||
|
isLoadingMoreRef.current = false;
|
||||||
setBrowseState((prev) =>
|
setBrowseState((prev) =>
|
||||||
prev.status === "loaded"
|
prev.status === "loaded"
|
||||||
? { status: "loaded", items: [...prev.items, ...items], nextCursor }
|
? { status: "loaded", items: [...prev.items, ...items], nextCursor }
|
||||||
: prev,
|
: prev,
|
||||||
);
|
);
|
||||||
|
setLoadMoreStatus("idle");
|
||||||
})
|
})
|
||||||
.catch(() => setBrowseState({ status: "error" }));
|
.catch(() => {
|
||||||
|
// Also clears a failed *prefetch*, not just a failed manual retry —
|
||||||
|
// otherwise a rejected promise would sit in the ref forever, and
|
||||||
|
// "Réessayer" would just keep reusing (and re-rejecting on) that
|
||||||
|
// same dead promise instead of ever making a fresh request.
|
||||||
|
nextPagePrefetchRef.current = null;
|
||||||
|
isLoadingMoreRef.current = false;
|
||||||
|
setLoadMoreStatus("error");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectItem(item: BrowsableSourceItemView) {
|
function handleSelectItem(item: BrowsableSourceItemView) {
|
||||||
|
|
@ -217,11 +312,17 @@ export function RecipeSourcesPanel({
|
||||||
items={browseState.items}
|
items={browseState.items}
|
||||||
selectedExternalId={selectedExternalId}
|
selectedExternalId={selectedExternalId}
|
||||||
onSelect={handleSelectItem}
|
onSelect={handleSelectItem}
|
||||||
|
placeholderCount={loadMoreStatus === "loading" ? LOAD_MORE_PLACEHOLDER_COUNT : 0}
|
||||||
|
hasMore={browseState.nextCursor !== null}
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
/>
|
/>
|
||||||
{browseState.nextCursor && (
|
{loadMoreStatus === "error" && (
|
||||||
<button type="button" className="source-items-load-more" onClick={handleLoadMore}>
|
<p className="recipes-page__status recipes-page__status--error">
|
||||||
{t("recipes.sources.loadMore")}
|
{t("recipes.sources.loadMoreError")}{" "}
|
||||||
</button>
|
<button type="button" className="source-items-retry" onClick={handleLoadMore}>
|
||||||
|
{t("recipes.sources.retry")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { BrowsableSourceItemView } from "@batch-cooking/shared";
|
import type { BrowsableSourceItemView } from "@batch-cooking/shared";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import "../recipes.scss";
|
import "../recipes.scss";
|
||||||
|
|
||||||
|
|
@ -8,20 +9,66 @@ import "../recipes.scss";
|
||||||
* an "already imported" badge in place of allergen/regime columns (a
|
* an "already imported" badge in place of allergen/regime columns (a
|
||||||
* source item has neither, it's not resolved against our catalogs until
|
* source item has neither, it's not resolved against our catalogs until
|
||||||
* previewed).
|
* previewed).
|
||||||
|
*
|
||||||
|
* Infinite scroll, not a "voir plus" button: a zero-content sentinel row
|
||||||
|
* (`source-item-table__sentinel`) sits right after `items`, watched by an
|
||||||
|
* `IntersectionObserver` scoped to this table's own scrolling container
|
||||||
|
* (`.recipe-table-wrap`, not the page) — scrolling it into view calls
|
||||||
|
* `onLoadMore`, the same callback a button's `onClick` would have. Kept
|
||||||
|
* entirely inside this component rather than exposed as a prop callback
|
||||||
|
* signature change on every call site: `RecipeSourcesPanel` doesn't need to
|
||||||
|
* know *how* "load more" gets triggered, only that it does.
|
||||||
*/
|
*/
|
||||||
export function SourceItemTable({
|
export function SourceItemTable({
|
||||||
items,
|
items,
|
||||||
selectedExternalId,
|
selectedExternalId,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
placeholderCount = 0,
|
||||||
|
hasMore = false,
|
||||||
|
onLoadMore,
|
||||||
}: {
|
}: {
|
||||||
items: BrowsableSourceItemView[];
|
items: BrowsableSourceItemView[];
|
||||||
selectedExternalId: string | null;
|
selectedExternalId: string | null;
|
||||||
onSelect: (item: BrowsableSourceItemView) => void;
|
onSelect: (item: BrowsableSourceItemView) => void;
|
||||||
|
/**
|
||||||
|
* Extra pulsing skeleton rows appended after `items` — `RecipeSourcesPanel`
|
||||||
|
* sets this while a "load more" fetch is in flight, so the list fills in
|
||||||
|
* right away instead of looking like nothing happened. Purely decorative:
|
||||||
|
* never clickable/focusable, unlike a real row.
|
||||||
|
*/
|
||||||
|
placeholderCount?: number;
|
||||||
|
/** Whether a further page exists — renders the sentinel row (and therefore observes it) only when true; the observer would otherwise have nothing meaningful to trigger once the catalog is exhausted. */
|
||||||
|
hasMore?: boolean;
|
||||||
|
/** Called once when the sentinel row scrolls into view — `RecipeSourcesPanel`'s own re-entrancy guard (not this component) is what keeps a still-visible sentinel from firing this repeatedly while a fetch is already in flight. */
|
||||||
|
onLoadMore?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const sentinelRef = useRef<HTMLTableRowElement | null>(null);
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: `onLoadMore` is deliberately excluded — RecipeSourcesPanel passes a fresh function identity on every render, and re-subscribing the observer on every single render (rather than only when hasMore actually flips) would be wasteful busywork for no behavioral difference.
|
||||||
|
useEffect(() => {
|
||||||
|
const root = scrollContainerRef.current;
|
||||||
|
const sentinel = sentinelRef.current;
|
||||||
|
if (!hasMore || !onLoadMore || !root || !sentinel) return;
|
||||||
|
|
||||||
|
// `rootMargin` starts loading the next page slightly before the
|
||||||
|
// sentinel actually reaches the visible edge — combined with
|
||||||
|
// RecipeSourcesPanel's own prefetch-ahead-of-time, the goal is that a
|
||||||
|
// page finishes arriving before anyone actually scrolls far enough to
|
||||||
|
// need it, not just "as soon as" they do.
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) onLoadMore();
|
||||||
|
},
|
||||||
|
{ root, rootMargin: "200px 0px" },
|
||||||
|
);
|
||||||
|
observer.observe(sentinel);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasMore]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="recipe-table-wrap">
|
<div className="recipe-table-wrap" ref={scrollContainerRef}>
|
||||||
<table className="recipe-table">
|
<table className="recipe-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -60,6 +107,23 @@ export function SourceItemTable({
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{hasMore && (
|
||||||
|
<tr ref={sentinelRef} className="source-item-table__sentinel">
|
||||||
|
<td colSpan={3} />
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{Array.from({ length: placeholderCount }, (_, index) => (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: a fixed-length run of interchangeable, content-less placeholders — there's no stable identity to key on, and the count never reorders.
|
||||||
|
<tr key={`skeleton-${index}`} className="source-item-table__row--skeleton">
|
||||||
|
<td>
|
||||||
|
<span className="recipe-table__photo source-item-table__photo--skeleton" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="source-item-table__skeleton-bar" />
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
71
apps/web/src/features/recipes/steps/CatalogSearchPicker.tsx
Normal file
71
apps/web/src/features/recipes/steps/CatalogSearchPicker.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small search-and-pick list — a lighter alternative to `IngredientPicker.tsx`
|
||||||
|
* (category/subcategory grid + allergen/diet toggles) for a context that
|
||||||
|
* doesn't have room for that: `TechStepCorrectionPopover.tsx`'s ingredient/
|
||||||
|
* utensil/**technique** pickers, all embedded in a small popover rather than
|
||||||
|
* a full recipe form. Reused for all three — an ingredient, a utensil, and a
|
||||||
|
* technique are all "search a reference list by translated label, pick one"
|
||||||
|
* from this component's point of view, the only difference is which
|
||||||
|
* `items`/labels the caller passes in. The technique catalog in particular
|
||||||
|
* (~74 entries) is exactly the case a plain unfiltered list stops being
|
||||||
|
* readable at — the original motivation for adding search here at all.
|
||||||
|
*
|
||||||
|
* Deliberately just `{ id, label }` in, `id` out — no `IngredientView`/
|
||||||
|
* `UtensilView`/`TechStepView` dependency here, so this stays reusable for
|
||||||
|
* any future "search this small reference catalog" need without growing a
|
||||||
|
* new prop per catalog shape.
|
||||||
|
*/
|
||||||
|
export function CatalogSearchPicker({
|
||||||
|
items,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
placeholder,
|
||||||
|
emptyLabel,
|
||||||
|
}: {
|
||||||
|
items: { id: number; label: string }[];
|
||||||
|
/** The currently-picked item, if any — marked with a distinct modifier class so it stays visible at a glance while browsing/filtering a longer list (e.g. `TechStepCorrectionPopover`'s ~74-entry technique catalog), not just implied by whatever's selected elsewhere on screen. Omit for a picker with no notion of a "current" pick (the ingredient/utensil span sub-flows — each `onSelect` there just appends a brand-new mention, nothing to mark as already chosen). */
|
||||||
|
selectedId?: number;
|
||||||
|
onSelect: (id: number) => void;
|
||||||
|
placeholder: string;
|
||||||
|
emptyLabel: string;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const visible =
|
||||||
|
normalizedQuery.length === 0
|
||||||
|
? items
|
||||||
|
: items.filter((item) => item.label.toLowerCase().includes(normalizedQuery));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="catalog-search-picker">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="catalog-search-picker__input"
|
||||||
|
/>
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<p className="catalog-search-picker__empty">{emptyLabel}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="catalog-search-picker__list">
|
||||||
|
{visible.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
item.id === selectedId ? "catalog-search-picker__item--selected" : undefined
|
||||||
|
}
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import type { StepTechStepView } from "@batch-cooking/shared";
|
import type { StepTechStepView, SubmitTechStepCorrectionResult } from "@batch-cooking/shared";
|
||||||
import { Fragment } from "react";
|
import { Fragment, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Tooltip } from "../../../components/ui/Tooltip";
|
import { Tooltip } from "../../../components/ui/Tooltip";
|
||||||
import { splitDescriptionByTechSteps } from "./highlight-tech-steps";
|
import { splitDescriptionByTechSteps } from "./highlight-tech-steps";
|
||||||
|
import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
|
||||||
|
import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A recipe step's description, with every detected technique's exact
|
* A recipe step's description, with every detected technique's exact
|
||||||
|
|
@ -23,48 +25,222 @@ import { splitDescriptionByTechSteps } from "./highlight-tech-steps";
|
||||||
*
|
*
|
||||||
* `techStep.key` resolves its tooltip label through `catalog.techSteps.<key>`
|
* `techStep.key` resolves its tooltip label through `catalog.techSteps.<key>`
|
||||||
* i18n, the same pattern every other reference catalog (diets, units, …)
|
* i18n, the same pattern every other reference catalog (diets, units, …)
|
||||||
* uses for its display text.
|
* uses for its display text. A keyword's `source` (`"auto"` — the
|
||||||
|
* classifier — vs `"manual"` — a viewer's correction, applied immediately)
|
||||||
|
* gets its own modifier class (`.step-tech-step--manual`), a different
|
||||||
|
* color, so the two are visually distinguishable at a glance rather than
|
||||||
|
* only via the tooltip text.
|
||||||
|
*
|
||||||
|
* `editable` (off by default) additionally lets the viewer select text or
|
||||||
|
* click an existing highlight to open a {@link TechStepCorrectionPopover} —
|
||||||
|
* see `use-text-selection.ts` for how a browser selection is translated
|
||||||
|
* into an absolute `[start, end)` span. When `!editable`, every segment
|
||||||
|
* renders exactly as before (no extra wrapping elements, no `data-offset`,
|
||||||
|
* no click handlers) — this mode is purely additive, not a rewrite of the
|
||||||
|
* read-only rendering.
|
||||||
|
*
|
||||||
|
* Maintains its own local copy of `techSteps` (seeded from the prop, then
|
||||||
|
* replaced with whatever `POST .../corrections` returns on a successful
|
||||||
|
* submit — see `SubmitTechStepCorrectionResult`'s doc comment,
|
||||||
|
* `packages/shared`) so a correction's effect (a new/relabeled/removed
|
||||||
|
* highlight) appears immediately, without needing the parent to re-fetch
|
||||||
|
* the whole recipe. Resynced whenever the `techSteps` prop itself changes
|
||||||
|
* (e.g. the parent reloaded the recipe for an unrelated reason) so this
|
||||||
|
* never keeps showing stale local state past that.
|
||||||
*/
|
*/
|
||||||
export function StepDescription({
|
export function StepDescription({
|
||||||
description,
|
description,
|
||||||
techSteps,
|
techSteps,
|
||||||
|
editable = false,
|
||||||
|
recipeId,
|
||||||
|
stepId,
|
||||||
}: {
|
}: {
|
||||||
description: string;
|
description: string;
|
||||||
techSteps: StepTechStepView[];
|
techSteps: StepTechStepView[];
|
||||||
|
/** Requires `recipeId`/`stepId` when `true` — omit (or leave `false`) for a read-only view with nothing real to correct against yet (e.g. `RecipeDetailPanel`'s `"loaded-draft"` unsaved-preview branch). */
|
||||||
|
editable?: boolean;
|
||||||
|
recipeId?: number;
|
||||||
|
stepId?: number;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const segments = splitDescriptionByTechSteps(description, techSteps);
|
const [liveTechSteps, setLiveTechSteps] = useState(techSteps);
|
||||||
|
useEffect(() => setLiveTechSteps(techSteps), [techSteps]);
|
||||||
|
|
||||||
|
const segments = splitDescriptionByTechSteps(description, liveTechSteps);
|
||||||
|
const containerRef = useRef<HTMLParagraphElement>(null);
|
||||||
|
const { getSelectionRange } = useTextSelection(containerRef);
|
||||||
|
|
||||||
|
const [activeCorrection, setActiveCorrection] = useState<{
|
||||||
|
range: TextSelectionRange;
|
||||||
|
selectedText: string;
|
||||||
|
previousTechStepId: number | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Routes the *next* text selection to the open `TechStepCorrectionPopover`
|
||||||
|
// (as an ingredient/utensil mention span) instead of opening a brand-new
|
||||||
|
// correction — set when that popover calls `onRequestSpan`, cleared once
|
||||||
|
// `handleMouseUp` resolves the selection below. See
|
||||||
|
// `TechStepCorrectionPopover.tsx`'s own doc comment for why this can live
|
||||||
|
// entirely alongside the still-visible, still-selectable description
|
||||||
|
// rather than needing the popover itself to move/hide.
|
||||||
|
const [pendingSpanRequest, setPendingSpanRequest] = useState<"ingredient" | "utensil" | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
|
||||||
|
nonce: number;
|
||||||
|
kind: "ingredient" | "utensil";
|
||||||
|
range: TextSelectionRange;
|
||||||
|
text: string;
|
||||||
|
} | null>(null);
|
||||||
|
const nextMetadataSpanNonce = useRef(0);
|
||||||
|
|
||||||
|
function closeActiveCorrection() {
|
||||||
|
setActiveCorrection(null);
|
||||||
|
setPendingSpanRequest(null);
|
||||||
|
setResolvedMetadataSpan(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseUp() {
|
||||||
|
if (!editable) return;
|
||||||
|
const range = getSelectionRange();
|
||||||
|
if (!range) return;
|
||||||
|
if (pendingSpanRequest !== null) {
|
||||||
|
nextMetadataSpanNonce.current += 1;
|
||||||
|
setResolvedMetadataSpan({
|
||||||
|
nonce: nextMetadataSpanNonce.current,
|
||||||
|
kind: pendingSpanRequest,
|
||||||
|
range,
|
||||||
|
text: description.slice(range.start, range.end),
|
||||||
|
});
|
||||||
|
setPendingSpanRequest(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveCorrection({
|
||||||
|
range,
|
||||||
|
selectedText: description.slice(range.start, range.end),
|
||||||
|
previousTechStepId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmitted(result: SubmitTechStepCorrectionResult) {
|
||||||
|
setLiveTechSteps(result.techSteps);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The occurrence `activeCorrection` is currently open for, matched by its
|
||||||
|
// exact `[start, end)` (not just `techStep.id` — the same technique can
|
||||||
|
// legitimately occur more than once in one description) — whatever
|
||||||
|
// ingredients/utensils it already carries seed
|
||||||
|
// `TechStepCorrectionPopover`'s own pending lists. `undefined` (not an
|
||||||
|
// empty array) for a brand-new selection, same as "nothing to look up
|
||||||
|
// yet".
|
||||||
|
const activeStepTechStep = activeCorrection
|
||||||
|
? liveTechSteps.find(
|
||||||
|
(techStep) =>
|
||||||
|
techStep.start === activeCorrection.range.start &&
|
||||||
|
techStep.end === activeCorrection.range.end,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Tracks each segment's own absolute start offset into `description` as
|
||||||
|
// the map below walks them in order — segments are contiguous and cover
|
||||||
|
// the whole description (see `splitDescriptionByTechSteps`'s doc
|
||||||
|
// comment), so a running total is exact, no re-derivation needed.
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p>
|
<>
|
||||||
{segments.map((segment, index) => {
|
<p ref={containerRef} onMouseUp={handleMouseUp}>
|
||||||
// A segment's own text/techStep don't uniquely identify it (the
|
{segments.map((segment, index) => {
|
||||||
// same word can appear twice in one description) — index is the
|
const start = offset;
|
||||||
// only thing that does, but this list is fully regenerated from
|
offset += segment.text.length;
|
||||||
// `description`/`techSteps` on every render (never reordered or
|
// Captured now, not read as `offset` later inside a click
|
||||||
// spliced in place), so using it as part of the key is safe here.
|
// handler below — `offset` keeps mutating for every subsequent
|
||||||
const key = `${index}-${segment.text}`;
|
// segment this same `.map()` pass renders, so a closure
|
||||||
if (!segment.techStep) return <Fragment key={key}>{segment.text}</Fragment>;
|
// referencing it directly would see its *final* value (the end
|
||||||
|
// of the whole description) whenever it actually fires, long
|
||||||
|
// after render — found via a real correction submitted with
|
||||||
|
// `end` far past this segment's own text.
|
||||||
|
const end = offset;
|
||||||
|
// A segment's own text/techStep don't uniquely identify it (the
|
||||||
|
// same word can appear twice in one description) — index is the
|
||||||
|
// only thing that does, but this list is fully regenerated from
|
||||||
|
// `description`/`liveTechSteps` on every render (never reordered
|
||||||
|
// or spliced in place), so using it as part of the key is safe
|
||||||
|
// here.
|
||||||
|
const key = `${index}-${segment.text}`;
|
||||||
|
|
||||||
if (!segment.isKeyword) {
|
if (!segment.techStep || !segment.isKeyword) {
|
||||||
// Context-only run — rendered as plain text, same as a segment
|
// Context-only or plain run — rendered as plain text in
|
||||||
// with no technique at all (see this component's doc comment for
|
// read-only mode, same as before this component supported
|
||||||
// why the wider-clause highlight was turned back off).
|
// `editable` at all (see this component's doc comment for why
|
||||||
return <Fragment key={key}>{segment.text}</Fragment>;
|
// the wider-clause highlight itself was turned back off).
|
||||||
}
|
// Editable mode still wraps it in a `data-offset` span so a
|
||||||
return (
|
// selection starting/ending in plain text resolves correctly.
|
||||||
<Tooltip key={key} content={t(`catalog.techSteps.${segment.techStep.key}`)}>
|
if (!editable) return <Fragment key={key}>{segment.text}</Fragment>;
|
||||||
{/* A real <button>, not a <mark>, so it's natively focusable
|
return (
|
||||||
(keyboard/screen-reader users can reach the tooltip) without
|
<span key={key} data-offset={start}>
|
||||||
fighting the "non-interactive element" a11y lint a bare
|
{segment.text}
|
||||||
tabIndex on <mark> would trip — styled to read as inline
|
</span>
|
||||||
highlighted text, not as a button (see .step-tech-step). */}
|
);
|
||||||
<button type="button" className="step-tech-step">
|
}
|
||||||
{segment.text}
|
|
||||||
</button>
|
const techStep = segment.techStep;
|
||||||
</Tooltip>
|
const isManual = segment.source === "manual";
|
||||||
);
|
const tooltipLabel = isManual
|
||||||
})}
|
? t("recipes.techStepCorrection.manualTooltip", {
|
||||||
</p>
|
technique: t(`catalog.techSteps.${techStep.key}`),
|
||||||
|
})
|
||||||
|
: t(`catalog.techSteps.${techStep.key}`);
|
||||||
|
return (
|
||||||
|
<Tooltip key={key} content={tooltipLabel}>
|
||||||
|
{/* A real <button>, not a <mark>, so it's natively focusable
|
||||||
|
(keyboard/screen-reader users can reach the tooltip) without
|
||||||
|
fighting the "non-interactive element" a11y lint a bare
|
||||||
|
tabIndex on <mark> would trip — styled to read as inline
|
||||||
|
highlighted text, not as a button (see .step-tech-step). */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={isManual ? "step-tech-step step-tech-step--manual" : "step-tech-step"}
|
||||||
|
data-offset={editable ? start : undefined}
|
||||||
|
onClick={
|
||||||
|
editable
|
||||||
|
? () => {
|
||||||
|
// Clears any in-progress ingredient/utensil
|
||||||
|
// span-selection from whatever correction was open
|
||||||
|
// before — opening a *different* one has nothing
|
||||||
|
// left to resolve that selection into.
|
||||||
|
setPendingSpanRequest(null);
|
||||||
|
setResolvedMetadataSpan(null);
|
||||||
|
setActiveCorrection({
|
||||||
|
range: { start, end },
|
||||||
|
selectedText: segment.text,
|
||||||
|
previousTechStepId: techStep.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{editable && activeCorrection && recipeId !== undefined && stepId !== undefined && (
|
||||||
|
<TechStepCorrectionPopover
|
||||||
|
recipeId={recipeId}
|
||||||
|
stepId={stepId}
|
||||||
|
range={activeCorrection.range}
|
||||||
|
selectedText={activeCorrection.selectedText}
|
||||||
|
previousTechStepId={activeCorrection.previousTechStepId}
|
||||||
|
existingIngredients={activeStepTechStep?.ingredients ?? []}
|
||||||
|
existingUtensils={activeStepTechStep?.utensils ?? []}
|
||||||
|
resolvedMetadataSpan={resolvedMetadataSpan}
|
||||||
|
onRequestSpan={setPendingSpanRequest}
|
||||||
|
onClose={closeActiveCorrection}
|
||||||
|
onSubmitted={handleSubmitted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,522 @@
|
||||||
|
import {
|
||||||
|
ErrorCode,
|
||||||
|
type IngredientView,
|
||||||
|
type StepTechStepIngredientView,
|
||||||
|
type StepTechStepUtensilView,
|
||||||
|
type SubmitTechStepCorrectionResult,
|
||||||
|
type TechStepView,
|
||||||
|
type UnitView,
|
||||||
|
type UtensilView,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ApiError, apiClient } from "../../../api/client";
|
||||||
|
import { errorMessageService } from "../../../services/error-message.service";
|
||||||
|
import { CatalogSearchPicker } from "./CatalogSearchPicker";
|
||||||
|
import type { TextSelectionRange } from "./use-text-selection";
|
||||||
|
|
||||||
|
/** One ingredient the viewer has attached (or is about to submit) — the trimmed-down shape `POST .../corrections`'s `ingredients[]` expects, kept separately from `StepTechStepIngredientView` since a pending one has no resolved `IngredientView`/`UnitView` to carry yet, only ids. */
|
||||||
|
interface PendingIngredient {
|
||||||
|
ingredientId: number;
|
||||||
|
quantity: number | null;
|
||||||
|
unitId: number | null;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
/** Same as {@link PendingIngredient}, for a utensil (no quantity/unit — nothing to measure). */
|
||||||
|
interface PendingUtensil {
|
||||||
|
utensilId: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPendingIngredient(view: StepTechStepIngredientView): PendingIngredient {
|
||||||
|
return {
|
||||||
|
ingredientId: view.ingredient.id,
|
||||||
|
quantity: view.quantity,
|
||||||
|
unitId: view.unit?.id ?? null,
|
||||||
|
start: view.start,
|
||||||
|
end: view.end,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
|
||||||
|
return { utensilId: view.utensil.id, start: view.start, end: view.end };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small non-modal popover letting a viewer assign a technique to a selected
|
||||||
|
* span of a step's description, or clear/relabel an existing match —
|
||||||
|
* opened by `StepDescription`'s editable mode. Same `mousedown`-outside-
|
||||||
|
* close pattern as `PlanningPage`'s `CalendarPopover`, not `Dialog.tsx`'s
|
||||||
|
* native `<dialog>` — this is a small, contextual pick-one-option surface,
|
||||||
|
* not a page-blocking modal.
|
||||||
|
*
|
||||||
|
* Rendered inline right below the step's own description block (see
|
||||||
|
* `StepDescription.tsx`), not floating anchored at the selection's exact
|
||||||
|
* position — simpler and more robust than tracking a caret-anchored
|
||||||
|
* position across scroll/resize, at the cost of a little visual distance
|
||||||
|
* from the selected text itself. That placement matters beyond cosmetics
|
||||||
|
* here: it's *why* the "attach an ingredient/utensil" flow below can ask
|
||||||
|
* the viewer to select a second span of text without closing this popover
|
||||||
|
* first — the description stays fully visible and selectable the whole
|
||||||
|
* time, nothing overlays it.
|
||||||
|
*
|
||||||
|
* **One merged editor, not a wizard**: picking a technique
|
||||||
|
* (`CatalogSearchPicker`, searchable — the reference catalog is ~74
|
||||||
|
* entries, an unfiltered flat list wasn't browsable) and editing its
|
||||||
|
* Ingrédients/Ustensiles metadata render together on the same screen,
|
||||||
|
* always — there's no separate "pick, then a metadata step reveals
|
||||||
|
* itself" sequence to go through, and no dead end where metadata is
|
||||||
|
* technically attachable but not visible until some other action happens
|
||||||
|
* first. A single "Valider" submits everything at once; disabled until a
|
||||||
|
* technique is actually selected (there's nothing to attach metadata to
|
||||||
|
* otherwise). **Removing** a match (`submit(null)`) stays its own
|
||||||
|
* immediate action next to the picker — nothing to attach when removing.
|
||||||
|
*
|
||||||
|
* The two metadata sections are pre-seeded from `existingIngredients`/
|
||||||
|
* `existingUtensils` (whatever's already attached to this occurrence, auto-
|
||||||
|
* or manually-sourced — `[]` for a brand-new technique) and editable via
|
||||||
|
* add/remove — see `metadataTouched` below for why what's *displayed* here
|
||||||
|
* isn't automatically what gets *submitted*.
|
||||||
|
*/
|
||||||
|
export function TechStepCorrectionPopover({
|
||||||
|
recipeId,
|
||||||
|
stepId,
|
||||||
|
selectedText,
|
||||||
|
range,
|
||||||
|
previousTechStepId,
|
||||||
|
existingIngredients,
|
||||||
|
existingUtensils,
|
||||||
|
resolvedMetadataSpan,
|
||||||
|
onRequestSpan,
|
||||||
|
onClose,
|
||||||
|
onSubmitted,
|
||||||
|
}: {
|
||||||
|
recipeId: number;
|
||||||
|
stepId: number;
|
||||||
|
/** The selected span's own text — shown so the user confirms what they're tagging before picking a technique. */
|
||||||
|
selectedText: string;
|
||||||
|
range: TextSelectionRange;
|
||||||
|
/** Set when correcting an already-detected match (opened from clicking its highlight) rather than a fresh selection — passed through as-is on submit, and offers a "remove" option `null` doesn't. */
|
||||||
|
previousTechStepId: number | null;
|
||||||
|
/** Whatever ingredients/utensils already sit on this occurrence (both `"auto"` and `"manual"` sourced) — `[]` for a brand-new technique, nothing to pre-seed. */
|
||||||
|
existingIngredients: StepTechStepIngredientView[];
|
||||||
|
existingUtensils: StepTechStepUtensilView[];
|
||||||
|
/**
|
||||||
|
* A text span `StepDescription` just resolved on this popover's behalf,
|
||||||
|
* after a call to `onRequestSpan` below — `null` until then. Identified
|
||||||
|
* by `nonce` (not by value) so this popover's own `useEffect` reliably
|
||||||
|
* fires once per fresh selection, even if the exact same span is
|
||||||
|
* selected twice in a row.
|
||||||
|
*/
|
||||||
|
resolvedMetadataSpan: {
|
||||||
|
nonce: number;
|
||||||
|
kind: "ingredient" | "utensil";
|
||||||
|
range: TextSelectionRange;
|
||||||
|
text: string;
|
||||||
|
} | null;
|
||||||
|
/** Tells `StepDescription` "the next text selection in the description is for an ingredient/utensil mention, not a new technique correction" — see this component's own doc comment. */
|
||||||
|
onRequestSpan: (kind: "ingredient" | "utensil") => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmitted: (result: SubmitTechStepCorrectionResult) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
|
||||||
|
// Opening this popover on an *already-detected* match (`previousTechStepId
|
||||||
|
// !== null`, i.e. the user clicked an existing highlight rather than
|
||||||
|
// selecting fresh text) starts pre-selected on that same technique, its
|
||||||
|
// name shown next to the picker right away — since the picker and the
|
||||||
|
// metadata sections render together regardless (see this component's own
|
||||||
|
// doc comment), this just saves re-picking the technique that's already
|
||||||
|
// correct before its metadata becomes editable.
|
||||||
|
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
|
||||||
|
const [catalogs, setCatalogs] = useState<{
|
||||||
|
ingredients: IngredientView[];
|
||||||
|
units: UnitView[];
|
||||||
|
utensils: UtensilView[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [pendingIngredients, setPendingIngredients] = useState<PendingIngredient[]>(() =>
|
||||||
|
existingIngredients.map(toPendingIngredient),
|
||||||
|
);
|
||||||
|
const [pendingUtensils, setPendingUtensils] = useState<PendingUtensil[]>(() =>
|
||||||
|
existingUtensils.map(toPendingUtensil),
|
||||||
|
);
|
||||||
|
// Flips true the moment the viewer adds/removes a pending entry — never
|
||||||
|
// from the initial seeding above. `submit()` below only includes
|
||||||
|
// `ingredients`/`utensils` in the request when this is true, so
|
||||||
|
// relabeling/confirming a technique without ever opening either section
|
||||||
|
// leaves existing metadata completely alone server-side (see
|
||||||
|
// `submitTechStepCorrectionSchema`'s own doc comment, `packages/shared`,
|
||||||
|
// for why an *omitted* field — not an empty array — is what "don't
|
||||||
|
// touch it" means over the wire).
|
||||||
|
const [metadataTouched, setMetadataTouched] = useState(false);
|
||||||
|
|
||||||
|
const [awaitingSpanFor, setAwaitingSpanFor] = useState<"ingredient" | "utensil" | null>(null);
|
||||||
|
const [activeSpan, setActiveSpan] = useState<{
|
||||||
|
kind: "ingredient" | "utensil";
|
||||||
|
range: TextSelectionRange;
|
||||||
|
text: string;
|
||||||
|
} | null>(null);
|
||||||
|
// Only meaningful while `activeSpan?.kind === "ingredient"` — the
|
||||||
|
// ingredient sub-flow is itself two steps (pick the ingredient, then its
|
||||||
|
// quantity/unit), this is where the first step's choice waits until the
|
||||||
|
// second is confirmed.
|
||||||
|
const [pickedIngredientId, setPickedIngredientId] = useState<number | null>(null);
|
||||||
|
const [spanQuantity, setSpanQuantity] = useState("");
|
||||||
|
const [spanUnitId, setSpanUnitId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
apiClient
|
||||||
|
.getTechSteps()
|
||||||
|
.then((list) => {
|
||||||
|
if (!cancelled) setTechSteps(list);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setTechSteps([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetched unconditionally on mount — the Ingrédients/Ustensiles sections
|
||||||
|
// render alongside the technique picker from the start (see this
|
||||||
|
// component's own doc comment on the merged editor), so there's no later
|
||||||
|
// point to defer this to anymore.
|
||||||
|
useEffect(() => {
|
||||||
|
if (catalogs !== null) return;
|
||||||
|
let cancelled = false;
|
||||||
|
Promise.all([apiClient.getIngredients(), apiClient.getUnits(), apiClient.getUtensils()])
|
||||||
|
.then(([ingredients, units, utensils]) => {
|
||||||
|
if (!cancelled) setCatalogs({ ingredients, units, utensils });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setCatalogs({ ingredients: [], units: [], utensils: [] });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [catalogs]);
|
||||||
|
|
||||||
|
// Consumes a span `StepDescription` just resolved on this popover's
|
||||||
|
// behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the
|
||||||
|
// matching sub-picker and clears the "awaiting a selection" hint.
|
||||||
|
useEffect(() => {
|
||||||
|
if (resolvedMetadataSpan === null) return;
|
||||||
|
setActiveSpan({
|
||||||
|
kind: resolvedMetadataSpan.kind,
|
||||||
|
range: resolvedMetadataSpan.range,
|
||||||
|
text: resolvedMetadataSpan.text,
|
||||||
|
});
|
||||||
|
setAwaitingSpanFor(null);
|
||||||
|
setPickedIngredientId(null);
|
||||||
|
setSpanQuantity("");
|
||||||
|
setSpanUnitId(null);
|
||||||
|
// Depends on the whole object, not just `.nonce` — `StepDescription`
|
||||||
|
// only ever calls its setter with a brand-new object (never mutates
|
||||||
|
// one in place), so reference equality alone already gives this the
|
||||||
|
// "fires once per fresh selection" behavior `nonce` documents, with no
|
||||||
|
// need to silence the exhaustive-deps lint to get there.
|
||||||
|
}, [resolvedMetadataSpan]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
async function removeMatch() {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await apiClient.submitTechStepCorrection(recipeId, stepId, {
|
||||||
|
start: range.start,
|
||||||
|
end: range.end,
|
||||||
|
previousTechStepId,
|
||||||
|
correctedTechStepId: null,
|
||||||
|
});
|
||||||
|
onSubmitted(result);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
|
setError(errorMessageService.getLabel(code));
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
if (selectedTechStepId === null) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await apiClient.submitTechStepCorrection(recipeId, stepId, {
|
||||||
|
start: range.start,
|
||||||
|
end: range.end,
|
||||||
|
previousTechStepId,
|
||||||
|
correctedTechStepId: selectedTechStepId,
|
||||||
|
...(metadataTouched ? { ingredients: pendingIngredients, utensils: pendingUtensils } : {}),
|
||||||
|
});
|
||||||
|
onSubmitted(result);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
|
setError(errorMessageService.getLabel(code));
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSpan(kind: "ingredient" | "utensil") {
|
||||||
|
setAwaitingSpanFor(kind);
|
||||||
|
onRequestSpan(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelSpanSelection() {
|
||||||
|
setAwaitingSpanFor(null);
|
||||||
|
setActiveSpan(null);
|
||||||
|
setPickedIngredientId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmIngredientSpan() {
|
||||||
|
if (activeSpan === null || pickedIngredientId === null) return;
|
||||||
|
const trimmed = spanQuantity.trim();
|
||||||
|
const parsedQuantity = trimmed.length > 0 ? Number(trimmed) : null;
|
||||||
|
setPendingIngredients((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
ingredientId: pickedIngredientId,
|
||||||
|
quantity:
|
||||||
|
parsedQuantity !== null && Number.isFinite(parsedQuantity) ? parsedQuantity : null,
|
||||||
|
unitId: spanUnitId,
|
||||||
|
start: activeSpan.range.start,
|
||||||
|
end: activeSpan.range.end,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setMetadataTouched(true);
|
||||||
|
setActiveSpan(null);
|
||||||
|
setPickedIngredientId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmUtensilSpan(utensilId: number) {
|
||||||
|
if (activeSpan === null) return;
|
||||||
|
setPendingUtensils((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ utensilId, start: activeSpan.range.start, end: activeSpan.range.end },
|
||||||
|
]);
|
||||||
|
setMetadataTouched(true);
|
||||||
|
setActiveSpan(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeIngredient(index: number) {
|
||||||
|
setPendingIngredients((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
setMetadataTouched(true);
|
||||||
|
}
|
||||||
|
function removeUtensil(index: number) {
|
||||||
|
setPendingUtensils((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
setMetadataTouched(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i]));
|
||||||
|
const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u]));
|
||||||
|
const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tech-step-correction-popover" ref={popoverRef}>
|
||||||
|
<p className="tech-step-correction-popover__selection">
|
||||||
|
{t("recipes.techStepCorrection.selectionLabel", { text: selectedText })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{awaitingSpanFor !== null ? (
|
||||||
|
<p className="tech-step-correction-popover__hint">
|
||||||
|
{t("recipes.techStepCorrection.selectSpanHint")}
|
||||||
|
</p>
|
||||||
|
) : activeSpan !== null ? (
|
||||||
|
<div className="tech-step-correction-popover__span-picker">
|
||||||
|
<p className="tech-step-correction-popover__selection">
|
||||||
|
{t("recipes.techStepCorrection.selectionLabel", { text: activeSpan.text })}
|
||||||
|
</p>
|
||||||
|
{activeSpan.kind === "ingredient" ? (
|
||||||
|
pickedIngredientId === null ? (
|
||||||
|
<CatalogSearchPicker
|
||||||
|
items={(catalogs?.ingredients ?? []).map((ingredient) => ({
|
||||||
|
id: ingredient.id,
|
||||||
|
label: t(`catalog.ingredients.${ingredient.key}`),
|
||||||
|
}))}
|
||||||
|
onSelect={setPickedIngredientId}
|
||||||
|
placeholder={t("recipes.form.searchIngredientPlaceholder")}
|
||||||
|
emptyLabel={t("recipes.form.noIngredientFound")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="tech-step-correction-popover__quantity-line">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={spanQuantity}
|
||||||
|
onChange={(e) => setSpanQuantity(e.target.value)}
|
||||||
|
aria-label={t("recipes.form.quantityLabel")}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={spanUnitId ?? ""}
|
||||||
|
onChange={(e) => setSpanUnitId(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
aria-label={t("recipes.form.unitLabel")}
|
||||||
|
>
|
||||||
|
<option value="">{t("recipes.form.unitPlaceholder")}</option>
|
||||||
|
{(catalogs?.units ?? []).map((unit) => (
|
||||||
|
<option key={unit.id} value={unit.id}>
|
||||||
|
{t(`catalog.units.${unit.key}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="button" onClick={confirmIngredientSpan}>
|
||||||
|
{t("recipes.techStepCorrection.addToList")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<CatalogSearchPicker
|
||||||
|
items={(catalogs?.utensils ?? []).map((utensil) => ({
|
||||||
|
id: utensil.id,
|
||||||
|
label: t(`catalog.utensils.${utensil.key}`),
|
||||||
|
}))}
|
||||||
|
onSelect={confirmUtensilSpan}
|
||||||
|
placeholder={t("recipes.techStepCorrection.searchUtensilPlaceholder")}
|
||||||
|
emptyLabel={t("recipes.techStepCorrection.noUtensilFound")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__cancel"
|
||||||
|
onClick={cancelSpanSelection}
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.cancelSpanSelection")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : techSteps === null ? (
|
||||||
|
<p>{t("recipes.loading")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="tech-step-correction-popover__confirm">
|
||||||
|
<section className="tech-step-correction-popover__technique-section">
|
||||||
|
<div className="tech-step-correction-popover__technique-header">
|
||||||
|
<h4>{t("recipes.techStepCorrection.techniqueSection")}</h4>
|
||||||
|
{previousTechStepId !== null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={removeMatch}
|
||||||
|
className="tech-step-correction-popover__remove"
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.removeMatch")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="tech-step-correction-popover__chosen-technique">
|
||||||
|
{selectedTechStepId !== null
|
||||||
|
? t("recipes.techStepCorrection.currentTechnique", {
|
||||||
|
technique: t(
|
||||||
|
`catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
: t("recipes.techStepCorrection.noTechniqueSelected")}
|
||||||
|
</p>
|
||||||
|
<CatalogSearchPicker
|
||||||
|
items={techSteps.map((techStep) => ({
|
||||||
|
id: techStep.id,
|
||||||
|
label: t(`catalog.techSteps.${techStep.key}`),
|
||||||
|
}))}
|
||||||
|
selectedId={selectedTechStepId ?? undefined}
|
||||||
|
onSelect={setSelectedTechStepId}
|
||||||
|
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
|
||||||
|
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="tech-step-correction-popover__metadata-section">
|
||||||
|
<h4>{t("recipes.techStepCorrection.ingredientsSection")}</h4>
|
||||||
|
<ul className="tech-step-correction-popover__chips">
|
||||||
|
{pendingIngredients.map((ingredient, index) => {
|
||||||
|
const view = ingredientById.get(ingredient.ingredientId);
|
||||||
|
const unit =
|
||||||
|
ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
|
||||||
|
const label = view ? t(`catalog.ingredients.${view.key}`) : "…";
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
|
||||||
|
<li key={index} className="tech-step-correction-popover__chip">
|
||||||
|
{ingredient.quantity !== null ? `${ingredient.quantity} ` : ""}
|
||||||
|
{unit ? `${t(`catalog.units.${unit.key}`)} ` : ""}
|
||||||
|
{label}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeIngredient(index)}
|
||||||
|
title={t("recipes.techStepCorrection.removeIngredient")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<button type="button" onClick={() => requestSpan("ingredient")} disabled={isSubmitting}>
|
||||||
|
{t("recipes.techStepCorrection.addIngredient")}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="tech-step-correction-popover__metadata-section">
|
||||||
|
<h4>{t("recipes.techStepCorrection.utensilsSection")}</h4>
|
||||||
|
<ul className="tech-step-correction-popover__chips">
|
||||||
|
{pendingUtensils.map((utensil, index) => {
|
||||||
|
const view = utensilById.get(utensil.utensilId);
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient chip list above.
|
||||||
|
<li key={index} className="tech-step-correction-popover__chip">
|
||||||
|
{view ? t(`catalog.utensils.${view.key}`) : "…"}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeUtensil(index)}
|
||||||
|
title={t("recipes.techStepCorrection.removeUtensil")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<button type="button" onClick={() => requestSpan("utensil")} disabled={isSubmitting}>
|
||||||
|
{t("recipes.techStepCorrection.addUtensil")}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__confirm-button"
|
||||||
|
onClick={confirm}
|
||||||
|
disabled={isSubmitting || selectedTechStepId === null}
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.confirm")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="field-error">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__cancel"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,8 @@ export interface DescriptionSegment {
|
||||||
techStep: StepTechStepView["techStep"] | null;
|
techStep: StepTechStepView["techStep"] | null;
|
||||||
/** Always `false` when `techStep` is `null`. */
|
/** Always `false` when `techStep` is `null`. */
|
||||||
isKeyword: boolean;
|
isKeyword: boolean;
|
||||||
|
/** Mirrors the source `StepTechStepView.source` this segment came from — `null` when `techStep` is `null` (nothing to attribute a source to). See `StepDescription.tsx` for how `"auto"` vs `"manual"` render differently. */
|
||||||
|
source: StepTechStepView["source"] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -26,34 +28,54 @@ export interface DescriptionSegment {
|
||||||
* (the keyword) and, when present, `contextStart`/`contextEnd` (the wider
|
* (the keyword) and, when present, `contextStart`/`contextEnd` (the wider
|
||||||
* clause it was found in — see `StepTechStepView`, resolved server-side by
|
* clause it was found in — see `StepTechStepView`, resolved server-side by
|
||||||
* `tech-step-matcher.ts`'s `matchTechStepSpans`). An entry with no context
|
* `tech-step-matcher.ts`'s `matchTechStepSpans`). An entry with no context
|
||||||
* (older data, saved before that column pair existed — see
|
* (older data, saved before that column pair existed, or a manual
|
||||||
* `StepTechStep`'s schema doc comment) degrades to a keyword-only segment,
|
* correction — see `StepTechStep`'s schema doc comment) degrades to a
|
||||||
* same as before context spans existed at all.
|
* keyword-only segment, same as before context spans existed at all.
|
||||||
*
|
*
|
||||||
* `techSteps` is expected already sorted by `start` (the API returns it in
|
* `techSteps` is expected already sorted by `start` (the API returns it in
|
||||||
* `StepTechStep.order`, which *is* reading order — see that model's schema
|
* `StepTechStep.order`, which *is* reading order — see that model's schema
|
||||||
* doc comment) but this re-sorts defensively (by context start when
|
* doc comment) but this re-sorts defensively by each entry's own tight
|
||||||
* present, since context always starts at or before its own keyword)
|
* `start` rather than assuming it, and silently drops any entry whose own
|
||||||
* rather than assuming it, and silently drops any entry whose bounds don't
|
* bounds don't make sense against `description` or a previously-accepted
|
||||||
* make sense against `description` or a previously-accepted entry's own
|
* entry's own tight keyword span — a malformed/out-of-date span degrades to
|
||||||
* bounds — a malformed/out-of-date span degrades to "just don't highlight
|
* "just don't highlight that one" rather than a garbled slice or a crash.
|
||||||
* that one" rather than a garbled slice or a crash.
|
*
|
||||||
|
* Two entries' tight keyword spans are never allowed to overlap (the later
|
||||||
|
* one is dropped, same as always), but two entries' wider *context* clauses
|
||||||
|
* are allowed to overlap each other and are silently clipped to make room —
|
||||||
|
* context is cosmetic only (`StepDescription.tsx` renders it identically to
|
||||||
|
* plain text) and must never cost a *different* entry its own real keyword
|
||||||
|
* highlight. This matters far more than it looks: a clause `splitIntoClauses`
|
||||||
|
* (`tech-step-matcher.ts`) found only one NER candidate in gets that
|
||||||
|
* candidate's context spanning the *entire* clause — often the entire
|
||||||
|
* description — so without clipping, a single auto-detected match anywhere
|
||||||
|
* in a step could silently swallow every manual correction added anywhere
|
||||||
|
* else in that same step's description, with no error, just an unstyled
|
||||||
|
* word in the rendered text. Found via live testing: a "simmer" match's
|
||||||
|
* whole-description context ate a manual "setAside" correction added to a
|
||||||
|
* later, otherwise-plain word in the same step.
|
||||||
*/
|
*/
|
||||||
export function splitDescriptionByTechSteps(
|
export function splitDescriptionByTechSteps(
|
||||||
description: string,
|
description: string,
|
||||||
techSteps: StepTechStepView[],
|
techSteps: StepTechStepView[],
|
||||||
): DescriptionSegment[] {
|
): DescriptionSegment[] {
|
||||||
const sorted = [...techSteps].sort(
|
const sorted = [...techSteps].sort((a, b) => a.start - b.start);
|
||||||
(a, b) => (a.contextStart ?? a.start) - (b.contextStart ?? b.start),
|
|
||||||
);
|
|
||||||
|
|
||||||
const segments: DescriptionSegment[] = [];
|
// First pass: decide which entries survive at all, using only each
|
||||||
let cursor = 0;
|
// entry's own tight keyword span for the cross-entry overlap check
|
||||||
for (const { techStep, start, end, contextStart, contextEnd } of sorted) {
|
// (`start < keywordCursor`) — the one thing two independent matches are
|
||||||
|
// never allowed to genuinely share. An entry's own context is still
|
||||||
|
// validated against its *own* keyword span here (`wideStart > start`,
|
||||||
|
// `end > wideEnd`, `wideEnd > description.length`) — a self-inconsistent
|
||||||
|
// span is dropped regardless of any other entry.
|
||||||
|
const valid: StepTechStepView[] = [];
|
||||||
|
let keywordCursor = 0;
|
||||||
|
for (const entry of sorted) {
|
||||||
|
const { start, end, contextStart, contextEnd } = entry;
|
||||||
const wideStart = contextStart ?? start;
|
const wideStart = contextStart ?? start;
|
||||||
const wideEnd = contextEnd ?? end;
|
const wideEnd = contextEnd ?? end;
|
||||||
if (
|
if (
|
||||||
wideStart < cursor ||
|
start < keywordCursor ||
|
||||||
wideStart > start ||
|
wideStart > start ||
|
||||||
start >= end ||
|
start >= end ||
|
||||||
end > wideEnd ||
|
end > wideEnd ||
|
||||||
|
|
@ -61,12 +83,31 @@ export function splitDescriptionByTechSteps(
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
valid.push(entry);
|
||||||
|
keywordCursor = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments: DescriptionSegment[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const [index, entry] of valid.entries()) {
|
||||||
|
const { techStep, start, end, contextStart, contextEnd, source } = entry;
|
||||||
|
const next = valid[index + 1];
|
||||||
|
// Clipped against `cursor` (this entry can't render context over
|
||||||
|
// territory already emitted) and the next surviving entry's own tight
|
||||||
|
// `start` (this entry's context can't reach into a neighbor's real
|
||||||
|
// keyword span) — provably within `[cursor, start]`/`[end, next.start]`
|
||||||
|
// respectively given `valid`'s own non-overlapping-tight-span
|
||||||
|
// invariant from the first pass, so never produces a negative-length
|
||||||
|
// slice.
|
||||||
|
const wideStart = Math.max(contextStart ?? start, cursor);
|
||||||
|
const wideEnd = Math.min(contextEnd ?? end, next?.start ?? description.length);
|
||||||
|
|
||||||
if (wideStart > cursor) {
|
if (wideStart > cursor) {
|
||||||
segments.push({
|
segments.push({
|
||||||
text: description.slice(cursor, wideStart),
|
text: description.slice(cursor, wideStart),
|
||||||
techStep: null,
|
techStep: null,
|
||||||
isKeyword: false,
|
isKeyword: false,
|
||||||
|
source: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (start > wideStart) {
|
if (start > wideStart) {
|
||||||
|
|
@ -74,16 +115,27 @@ export function splitDescriptionByTechSteps(
|
||||||
text: description.slice(wideStart, start),
|
text: description.slice(wideStart, start),
|
||||||
techStep,
|
techStep,
|
||||||
isKeyword: false,
|
isKeyword: false,
|
||||||
|
source,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
segments.push({ text: description.slice(start, end), techStep, isKeyword: true });
|
segments.push({ text: description.slice(start, end), techStep, isKeyword: true, source });
|
||||||
if (wideEnd > end) {
|
if (wideEnd > end) {
|
||||||
segments.push({ text: description.slice(end, wideEnd), techStep, isKeyword: false });
|
segments.push({
|
||||||
|
text: description.slice(end, wideEnd),
|
||||||
|
techStep,
|
||||||
|
isKeyword: false,
|
||||||
|
source,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
cursor = wideEnd;
|
cursor = wideEnd;
|
||||||
}
|
}
|
||||||
if (cursor < description.length) {
|
if (cursor < description.length) {
|
||||||
segments.push({ text: description.slice(cursor), techStep: null, isKeyword: false });
|
segments.push({
|
||||||
|
text: description.slice(cursor),
|
||||||
|
techStep: null,
|
||||||
|
isKeyword: false,
|
||||||
|
source: null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
84
apps/web/src/features/recipes/steps/use-text-selection.ts
Normal file
84
apps/web/src/features/recipes/steps/use-text-selection.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { type RefObject, useCallback } from "react";
|
||||||
|
|
||||||
|
/** A `[start, end)` character range into a step's original `description` string — same convention as `StepTechStepView.start`/`end`. */
|
||||||
|
export interface TextSelectionRange {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the browser's current text selection, translated into a
|
||||||
|
* {@link TextSelectionRange} into a step's original `description` string —
|
||||||
|
* the shape `POST /recipes/:id/steps/:stepId/corrections` expects (see
|
||||||
|
* `SubmitTechStepCorrectionInput`, `packages/shared`).
|
||||||
|
*
|
||||||
|
* Works by walking up from each end of the selection's `Range` to the
|
||||||
|
* nearest ancestor carrying a `data-offset` attribute — set by
|
||||||
|
* `StepDescription`'s editable mode on every {@link DescriptionSegment}'s
|
||||||
|
* own wrapping element (`<span>`/`<button>`, `StepDescription.tsx`), each
|
||||||
|
* wrapping exactly its own text run and nothing else. `data-offset`'s value
|
||||||
|
* is that segment's own absolute start offset into `description`; added to
|
||||||
|
* the in-node offset the `Range` reports, this gives an exact absolute
|
||||||
|
* offset without needing to serialize/re-measure any text.
|
||||||
|
*
|
||||||
|
* Deliberately *not* using the more common `Range.toString().length`-from-
|
||||||
|
* the-container's-start technique for this problem — `StepDescription`
|
||||||
|
* always renders a `Tooltip` bubble alongside a keyword segment's own
|
||||||
|
* `<button>` (`Tooltip.tsx`, hidden via CSS, not removed from the DOM),
|
||||||
|
* whose text would silently pad that count past any keyword segment,
|
||||||
|
* corrupting every offset downstream of one.
|
||||||
|
*/
|
||||||
|
export function useTextSelection(containerRef: RefObject<HTMLElement | null>): {
|
||||||
|
getSelectionRange: () => TextSelectionRange | null;
|
||||||
|
} {
|
||||||
|
const getSelectionRange = useCallback((): TextSelectionRange | null => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!selection || selection.isCollapsed || selection.rangeCount === 0 || !container) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
if (!container.contains(range.commonAncestorContainer)) return null;
|
||||||
|
|
||||||
|
const start = resolveOffset(container, range.startContainer, range.startOffset);
|
||||||
|
const end = resolveOffset(container, range.endContainer, range.endOffset);
|
||||||
|
if (start === null || end === null || start === end) return null;
|
||||||
|
|
||||||
|
return start < end ? { start, end } : { start: end, end: start };
|
||||||
|
}, [containerRef]);
|
||||||
|
|
||||||
|
return { getSelectionRange };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves `(node, nodeOffset)` (one end of a DOM `Range`, in the DOM's own
|
||||||
|
* mixed node/character-index convention) to an absolute character offset
|
||||||
|
* into `description`, or `null` if `node` isn't inside a segment
|
||||||
|
* `StepDescription` wrapped with `data-offset` at all — a selection edge
|
||||||
|
* that lands on whitespace/structure outside any segment shouldn't occur
|
||||||
|
* given every segment is wrapped, but this degrades to "no valid
|
||||||
|
* selection" rather than a wrong span or a crash if it somehow does.
|
||||||
|
*/
|
||||||
|
function resolveOffset(container: HTMLElement, node: Node, nodeOffset: number): number | null {
|
||||||
|
// A `Range` boundary that lands exactly on a segment's own wrapping
|
||||||
|
// element (rather than diving into its single Text child) reports
|
||||||
|
// `nodeOffset` as a *child index* (`0` or `1`, since every segment wraps
|
||||||
|
// exactly one Text node) — not a character offset. Resolved to the
|
||||||
|
// matching character offset (segment start vs. segment end) up front, so
|
||||||
|
// the walk below only ever deals in character offsets from here on.
|
||||||
|
let charOffset = nodeOffset;
|
||||||
|
if (node instanceof HTMLElement) {
|
||||||
|
const textChild = node.firstChild;
|
||||||
|
charOffset = nodeOffset > 0 ? (textChild?.textContent?.length ?? 0) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: Node | null = node;
|
||||||
|
while (current && current !== container) {
|
||||||
|
if (current instanceof HTMLElement && current.dataset.offset !== undefined) {
|
||||||
|
return Number(current.dataset.offset) + charOffset;
|
||||||
|
}
|
||||||
|
current = current.parentNode;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,26 @@
|
||||||
"common": {
|
"common": {
|
||||||
"saving": "Enregistrement…",
|
"saving": "Enregistrement…",
|
||||||
"saved": "Enregistré ✓",
|
"saved": "Enregistré ✓",
|
||||||
"loadError": "Impossible de charger le planning, réessayez plus tard"
|
"loadError": "Impossible de charger le planning, réessayez plus tard",
|
||||||
|
"weekNav": {
|
||||||
|
"thisWeek": "Cette semaine",
|
||||||
|
"prevWeek": "Semaine précédente",
|
||||||
|
"nextWeek": "Semaine suivante",
|
||||||
|
"label": "Semaine du {{range}}"
|
||||||
|
},
|
||||||
|
"calendar": {
|
||||||
|
"prevMonth": "Mois précédent",
|
||||||
|
"nextMonth": "Mois suivant"
|
||||||
|
},
|
||||||
|
"days": {
|
||||||
|
"lundi": "Lundi",
|
||||||
|
"mardi": "Mardi",
|
||||||
|
"mercredi": "Mercredi",
|
||||||
|
"jeudi": "Jeudi",
|
||||||
|
"vendredi": "Vendredi",
|
||||||
|
"samedi": "Samedi",
|
||||||
|
"dimanche": "Dimanche"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"VALIDATION_ERROR": "Erreur de validation",
|
"VALIDATION_ERROR": "Erreur de validation",
|
||||||
|
|
@ -23,6 +42,10 @@
|
||||||
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
|
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
|
||||||
"UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas",
|
"UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas",
|
||||||
"SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas",
|
"SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas",
|
||||||
|
"STEP_NOT_FOUND": "Cette étape n'existe pas",
|
||||||
|
"TECH_STEP_NOT_FOUND": "Cette technique n'existe pas",
|
||||||
|
"UTENSIL_NOT_FOUND": "Un des ustensiles sélectionnés n'existe pas",
|
||||||
|
"INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape",
|
||||||
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
|
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
|
|
@ -99,25 +122,6 @@
|
||||||
"planning": {
|
"planning": {
|
||||||
"title": "Planning de la semaine",
|
"title": "Planning de la semaine",
|
||||||
"loading": "Chargement du planning…",
|
"loading": "Chargement du planning…",
|
||||||
"weekNav": {
|
|
||||||
"thisWeek": "Cette semaine",
|
|
||||||
"prevWeek": "Semaine précédente",
|
|
||||||
"nextWeek": "Semaine suivante",
|
|
||||||
"label": "Semaine du {{range}}"
|
|
||||||
},
|
|
||||||
"calendar": {
|
|
||||||
"prevMonth": "Mois précédent",
|
|
||||||
"nextMonth": "Mois suivant"
|
|
||||||
},
|
|
||||||
"days": {
|
|
||||||
"lundi": "Lundi",
|
|
||||||
"mardi": "Mardi",
|
|
||||||
"mercredi": "Mercredi",
|
|
||||||
"jeudi": "Jeudi",
|
|
||||||
"vendredi": "Vendredi",
|
|
||||||
"samedi": "Samedi",
|
|
||||||
"dimanche": "Dimanche"
|
|
||||||
},
|
|
||||||
"meals": {
|
"meals": {
|
||||||
"petit-dejeuner": "Petit-déjeuner",
|
"petit-dejeuner": "Petit-déjeuner",
|
||||||
"collation": "Collation",
|
"collation": "Collation",
|
||||||
|
|
@ -160,6 +164,30 @@
|
||||||
"cancelDeleteButton": "Annuler",
|
"cancelDeleteButton": "Annuler",
|
||||||
"ingredientsTitle": "Ingrédients",
|
"ingredientsTitle": "Ingrédients",
|
||||||
"stepsTitle": "Préparation",
|
"stepsTitle": "Préparation",
|
||||||
|
"techStepCorrection": {
|
||||||
|
"selectionLabel": "« {{text}} »",
|
||||||
|
"removeMatch": "Aucune technique ici",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"manualTooltip": "{{technique}} (correction manuelle)",
|
||||||
|
"discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger.",
|
||||||
|
"confirm": "Valider",
|
||||||
|
"techniqueSection": "Technique",
|
||||||
|
"currentTechnique": "Technique retenue : {{technique}}",
|
||||||
|
"noTechniqueSelected": "Aucune technique sélectionnée.",
|
||||||
|
"searchTechniquePlaceholder": "Rechercher une technique…",
|
||||||
|
"noTechniqueFound": "Aucune technique trouvée.",
|
||||||
|
"ingredientsSection": "Ingrédients",
|
||||||
|
"utensilsSection": "Ustensiles",
|
||||||
|
"addIngredient": "+ Ajouter un ingrédient",
|
||||||
|
"addUtensil": "+ Ajouter un ustensile",
|
||||||
|
"removeIngredient": "Retirer cet ingrédient",
|
||||||
|
"removeUtensil": "Retirer cet ustensile",
|
||||||
|
"selectSpanHint": "Sélectionnez le passage de texte concerné dans la description ci-dessus…",
|
||||||
|
"cancelSpanSelection": "Annuler la sélection",
|
||||||
|
"searchUtensilPlaceholder": "Rechercher un ustensile…",
|
||||||
|
"noUtensilFound": "Aucun ustensile trouvé.",
|
||||||
|
"addToList": "Ajouter"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"favoris": "Favoris",
|
"favoris": "Favoris",
|
||||||
"perso": "Perso",
|
"perso": "Perso",
|
||||||
|
|
@ -174,10 +202,11 @@
|
||||||
"sources": {
|
"sources": {
|
||||||
"searchPlaceholder": "Rechercher…",
|
"searchPlaceholder": "Rechercher…",
|
||||||
"empty": "Aucune recette trouvée.",
|
"empty": "Aucune recette trouvée.",
|
||||||
"loadMore": "Voir plus",
|
|
||||||
"alreadyImported": "Déjà importée",
|
"alreadyImported": "Déjà importée",
|
||||||
"loading": "Chargement…",
|
"loading": "Chargement…",
|
||||||
"loadError": "Impossible de charger cette source pour le moment.",
|
"loadError": "Impossible de charger cette source pour le moment.",
|
||||||
|
"loadMoreError": "Impossible de charger la suite pour le moment.",
|
||||||
|
"retry": "Réessayer",
|
||||||
"detail": {
|
"detail": {
|
||||||
"viewSource": "Voir sur le site d'origine"
|
"viewSource": "Voir sur le site d'origine"
|
||||||
},
|
},
|
||||||
|
|
@ -278,7 +307,8 @@
|
||||||
},
|
},
|
||||||
"shoppingList": {
|
"shoppingList": {
|
||||||
"title": "Liste de courses",
|
"title": "Liste de courses",
|
||||||
"comingSoon": "Cette section arrive bientôt."
|
"loading": "Chargement de la liste de courses…",
|
||||||
|
"empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning."
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"title": "Compte",
|
"title": "Compte",
|
||||||
|
|
@ -413,7 +443,87 @@
|
||||||
"preheat": "Préchauffer",
|
"preheat": "Préchauffer",
|
||||||
"bake": "Cuire au four",
|
"bake": "Cuire au four",
|
||||||
"plate": "Dresser",
|
"plate": "Dresser",
|
||||||
"coat": "Napper"
|
"coat": "Napper",
|
||||||
|
"baste": "Arroser",
|
||||||
|
"appertize": "Appertiser",
|
||||||
|
"whiskPale": "Blanchir (jaunes d'œufs)",
|
||||||
|
"goldenBrown": "Blondir",
|
||||||
|
"braise": "Braiser",
|
||||||
|
"truss": "Brider",
|
||||||
|
"caramelize": "Caraméliser",
|
||||||
|
"score": "Cerner",
|
||||||
|
"lineMold": "Chemiser",
|
||||||
|
"clarify": "Clarifier",
|
||||||
|
"compote": "Compoter",
|
||||||
|
"concasse": "Concasser",
|
||||||
|
"confit": "Confire",
|
||||||
|
"julienne": "Couper en julienne",
|
||||||
|
"brunoise": "Couper en brunoise",
|
||||||
|
"mirepoix": "Couper en mirepoix",
|
||||||
|
"paysanne": "Couper en paysanne",
|
||||||
|
"blindBake": "Cuire à blanc",
|
||||||
|
"bainMarie": "Cuire au bain-marie",
|
||||||
|
"smother": "Cuire à l'étouffée",
|
||||||
|
"decant": "Décanter",
|
||||||
|
"dilute": "Délayer",
|
||||||
|
"punchDown": "Dégazer",
|
||||||
|
"disgorge": "Dégorger",
|
||||||
|
"loosen": "Détendre",
|
||||||
|
"shellEgg": "Écaler",
|
||||||
|
"scald": "Échauder",
|
||||||
|
"pod": "Écosser",
|
||||||
|
"emulsify": "Émulsionner",
|
||||||
|
"hollowOut": "Évider",
|
||||||
|
"shock": "Frapper",
|
||||||
|
"setGel": "Gélifier",
|
||||||
|
"glaze": "Glacer",
|
||||||
|
"thicken": "Lier",
|
||||||
|
"filet": "Lever les filets",
|
||||||
|
"proof": "Laisser pousser",
|
||||||
|
"peelBlanch": "Monder",
|
||||||
|
"whipUp": "Monter",
|
||||||
|
"moisten": "Mouiller",
|
||||||
|
"pasteurize": "Pasteuriser",
|
||||||
|
"poach": "Pocher",
|
||||||
|
"reduce": "Réduire",
|
||||||
|
"rubIn": "Sabler",
|
||||||
|
"dustWithFlour": "Singer",
|
||||||
|
"sweat": "Suer",
|
||||||
|
"sift": "Tamiser",
|
||||||
|
"toast": "Torréfier",
|
||||||
|
"zest": "Zester"
|
||||||
|
},
|
||||||
|
"utensils": {
|
||||||
|
"pan": "Poêle",
|
||||||
|
"saucepan": "Casserole",
|
||||||
|
"pot": "Marmite",
|
||||||
|
"knife": "Couteau",
|
||||||
|
"whisk": "Fouet",
|
||||||
|
"bowl": "Saladier",
|
||||||
|
"bakingSheet": "Plaque de cuisson",
|
||||||
|
"mold": "Moule",
|
||||||
|
"colander": "Passoire",
|
||||||
|
"cuttingBoard": "Planche à découper",
|
||||||
|
"oven": "Four",
|
||||||
|
"blender": "Blender",
|
||||||
|
"mixer": "Batteur",
|
||||||
|
"spatula": "Spatule",
|
||||||
|
"ladle": "Louche",
|
||||||
|
"grater": "Râpe",
|
||||||
|
"rollingPin": "Rouleau à pâtisserie",
|
||||||
|
"lid": "Couvercle",
|
||||||
|
"tongs": "Pince de cuisine",
|
||||||
|
"peeler": "Économe",
|
||||||
|
"sieve": "Tamis",
|
||||||
|
"foodProcessor": "Robot ménager",
|
||||||
|
"steamerBasket": "Panier vapeur",
|
||||||
|
"skewer": "Brochette",
|
||||||
|
"pastryBrush": "Pinceau de cuisine",
|
||||||
|
"ramekin": "Ramequin",
|
||||||
|
"dish": "Plat",
|
||||||
|
"wok": "Wok",
|
||||||
|
"thermometer": "Thermomètre",
|
||||||
|
"mandoline": "Mandoline"
|
||||||
},
|
},
|
||||||
"allergens": {
|
"allergens": {
|
||||||
"gluten": "Gluten",
|
"gluten": "Gluten",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
import {
|
import { DateTime, formatDateOnly, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
|
||||||
addWeeks,
|
|
||||||
buildCalendarMonth,
|
|
||||||
DateTime,
|
|
||||||
formatDateOnly,
|
|
||||||
getWeekStart,
|
|
||||||
toDateOnly,
|
|
||||||
} from "@batch-cooking/date-tools";
|
|
||||||
import {
|
import {
|
||||||
MEALS,
|
MEALS,
|
||||||
type Meal,
|
type Meal,
|
||||||
|
|
@ -13,10 +6,11 @@ import {
|
||||||
type PlanningView,
|
type PlanningView,
|
||||||
WEEK_DAYS,
|
WEEK_DAYS,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { apiClient } from "../../api/client";
|
import { apiClient } from "../../api/client";
|
||||||
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
|
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
|
||||||
|
import { WeekNavigator } from "../../features/planning/WeekNavigator";
|
||||||
import "./planning-page.scss";
|
import "./planning-page.scss";
|
||||||
|
|
||||||
/** Load state for the `GET /planning` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */
|
/** Load state for the `GET /planning` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */
|
||||||
|
|
@ -49,7 +43,7 @@ export function PlanningPage() {
|
||||||
// closed. Mounting the dialog only while this is set (rather than an
|
// closed. Mounting the dialog only while this is set (rather than an
|
||||||
// always-mounted `isOpen` toggle) resets its internal filter/search
|
// always-mounted `isOpen` toggle) resets its internal filter/search
|
||||||
// state for free on every open, same convention as `WeekNavigator`'s own
|
// state for free on every open, same convention as `WeekNavigator`'s own
|
||||||
// `CalendarPopover` below.
|
// `CalendarPopover` (features/planning/WeekNavigator.tsx).
|
||||||
const [openSlot, setOpenSlot] = useState<PlanningSlot | null>(null);
|
const [openSlot, setOpenSlot] = useState<PlanningSlot | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -144,160 +138,6 @@ export function PlanningPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "17 au 23 août 2026" — collapses the month/year to just the end date when both ends of the week share it, spells it out on both ends otherwise (e.g. a week straddling two months). */
|
|
||||||
function formatWeekRange(weekStart: DateTime): string {
|
|
||||||
const weekEnd = weekStart.plus({ days: 6 });
|
|
||||||
const sameMonth = weekStart.hasSame(weekEnd, "month");
|
|
||||||
const startLabel = weekStart.toLocaleString(
|
|
||||||
sameMonth ? { day: "numeric" } : { day: "numeric", month: "long" },
|
|
||||||
{ locale: "fr" },
|
|
||||||
);
|
|
||||||
const endLabel = weekEnd.toLocaleString(
|
|
||||||
{ day: "numeric", month: "long", year: "numeric" },
|
|
||||||
{ locale: "fr" },
|
|
||||||
);
|
|
||||||
return `${startLabel} au ${endLabel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Arrows + clickable label opening {@link CalendarPopover} — the week-selection UI at the top of the page. */
|
|
||||||
function WeekNavigator({
|
|
||||||
weekStart,
|
|
||||||
onChangeWeek,
|
|
||||||
}: {
|
|
||||||
weekStart: DateTime;
|
|
||||||
onChangeWeek: (weekStart: DateTime) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
|
||||||
const isThisWeek = weekStart.hasSame(getWeekStart(DateTime.utc()), "day");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="week-nav">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="week-nav__arrow"
|
|
||||||
title={t("planning.weekNav.prevWeek")}
|
|
||||||
onClick={() => onChangeWeek(addWeeks(weekStart, -1))}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="week-nav__label"
|
|
||||||
onClick={() => setIsCalendarOpen((open) => !open)}
|
|
||||||
>
|
|
||||||
📅 {t("planning.weekNav.label", { range: formatWeekRange(weekStart) })}
|
|
||||||
{isThisWeek && <span className="today-badge">{t("planning.weekNav.thisWeek")}</span>}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="week-nav__arrow"
|
|
||||||
title={t("planning.weekNav.nextWeek")}
|
|
||||||
onClick={() => onChangeWeek(addWeeks(weekStart, 1))}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isCalendarOpen && (
|
|
||||||
<CalendarPopover
|
|
||||||
selectedWeekStart={weekStart}
|
|
||||||
onSelectDay={(day) => {
|
|
||||||
onChangeWeek(getWeekStart(day));
|
|
||||||
setIsCalendarOpen(false);
|
|
||||||
}}
|
|
||||||
onClose={() => setIsCalendarOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Month calendar letting the visitor jump to any week at once — selecting a day selects its whole (Monday-first) week. Closes itself on an outside click. */
|
|
||||||
function CalendarPopover({
|
|
||||||
selectedWeekStart,
|
|
||||||
onSelectDay,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
selectedWeekStart: DateTime;
|
|
||||||
onSelectDay: (day: DateTime) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
// Its own state: browsing to a different month to pick a week there
|
|
||||||
// shouldn't jump back every render — only re-anchors when the popover is
|
|
||||||
// first opened (`selectedWeekStart` at that point), not while it's open.
|
|
||||||
const [visibleMonth, setVisibleMonth] = useState(() => selectedWeekStart.startOf("month"));
|
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const today = toDateOnly(DateTime.utc());
|
|
||||||
const selectedWeekEnd = selectedWeekStart.plus({ days: 6 });
|
|
||||||
const weeks = buildCalendarMonth(visibleMonth);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="calendar-popover" ref={popoverRef}>
|
|
||||||
<div className="calendar-popover__header">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={t("planning.calendar.prevMonth")}
|
|
||||||
onClick={() => setVisibleMonth((month) => month.minus({ months: 1 }))}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<span>
|
|
||||||
{visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={t("planning.calendar.nextMonth")}
|
|
||||||
onClick={() => setVisibleMonth((month) => month.plus({ months: 1 }))}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="calendar-grid">
|
|
||||||
{WEEK_DAYS.map((weekDay) => (
|
|
||||||
<span key={weekDay} className="calendar-grid__weekday">
|
|
||||||
{t(`planning.days.${weekDay}`).charAt(0)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{weeks.flat().map((day) => {
|
|
||||||
const classNames = ["calendar-grid__day"];
|
|
||||||
if (!day.hasSame(visibleMonth, "month")) classNames.push("calendar-grid__day--muted");
|
|
||||||
if (day >= selectedWeekStart && day <= selectedWeekEnd) {
|
|
||||||
classNames.push("calendar-grid__day--in-selected-week");
|
|
||||||
}
|
|
||||||
if (day.hasSame(today, "day")) classNames.push("calendar-grid__day--today");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={day.toISO()}
|
|
||||||
type="button"
|
|
||||||
className={classNames.join(" ")}
|
|
||||||
onClick={() => onSelectDay(day)}
|
|
||||||
>
|
|
||||||
{day.day}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The week grid itself — 7 day columns × 5 meal rows. */
|
/** The week grid itself — 7 day columns × 5 meal rows. */
|
||||||
function PlanningGrid({
|
function PlanningGrid({
|
||||||
weekStart,
|
weekStart,
|
||||||
|
|
@ -323,7 +163,7 @@ function PlanningGrid({
|
||||||
<th />
|
<th />
|
||||||
{days.map(({ weekDay, date }) => (
|
{days.map(({ weekDay, date }) => (
|
||||||
<th key={weekDay} className={date.hasSame(today, "day") ? "today" : undefined}>
|
<th key={weekDay} className={date.hasSame(today, "day") ? "today" : undefined}>
|
||||||
<span className="day-name">{t(`planning.days.${weekDay}`)}</span>
|
<span className="day-name">{t(`common.days.${weekDay}`)}</span>
|
||||||
<span className="day-date">{date.day}</span>
|
<span className="day-date">{date.day}</span>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -38,143 +38,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Week navigator (arrows + clickable label opening the calendar) -------
|
|
||||||
.week-nav {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
|
|
||||||
&__arrow {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
padding: 0.45rem var(--space-md);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-badge {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-primary);
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Calendar popover -------------------------------------------------------
|
|
||||||
.calendar-popover {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + var(--space-xs));
|
|
||||||
right: 0;
|
|
||||||
z-index: 10;
|
|
||||||
width: 18rem;
|
|
||||||
padding: var(--space-md);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
|
|
||||||
&__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
text-transform: capitalize;
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 1.6rem;
|
|
||||||
height: 1.6rem;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, 1fr);
|
|
||||||
gap: 2px;
|
|
||||||
|
|
||||||
&__weekday {
|
|
||||||
text-align: center;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
padding-bottom: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__day {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-text);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
font: inherit;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--muted {
|
|
||||||
color: var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--in-selected-week {
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--today {
|
|
||||||
box-shadow: inset 0 0 0 2px var(--color-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- The grid itself --------------------------------------------------------
|
// --- The grid itself --------------------------------------------------------
|
||||||
|
// (Week navigator + calendar popover styles now live in
|
||||||
|
// features/planning/week-navigator.scss, imported by WeekNavigator.tsx
|
||||||
|
// directly — extracted once ShoppingListPage started reusing that
|
||||||
|
// component too.)
|
||||||
.planning-grid-wrapper {
|
.planning-grid-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,114 @@
|
||||||
|
import { DateTime, formatDateOnly, getWeekStart } from "@batch-cooking/date-tools";
|
||||||
|
import type { ShoppingListItemView, ShoppingListView } from "@batch-cooking/shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ComingSoonPage } from "../../components/ui/ComingSoonPage";
|
import { apiClient } from "../../api/client";
|
||||||
|
import { WeekNavigator } from "../../features/planning/WeekNavigator";
|
||||||
|
import {
|
||||||
|
CategoryIcon,
|
||||||
|
IngredientTypeIcon,
|
||||||
|
} from "../../features/recipes/ingredients/ingredient-icons";
|
||||||
|
import { formatShoppingListQuantity, groupShoppingListItems } from "./shopping-list";
|
||||||
|
import "./shopping-list-page.scss";
|
||||||
|
|
||||||
/** Shopping list section — routed at `/liste-de-courses`. No backend yet, stub for now. */
|
/** Load state for the `GET /shopping-list` call — same discriminated-union shape as `PlanningPage`'s own `PlanningState`. */
|
||||||
|
type ShoppingListState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "loaded"; list: ShoppingListView }
|
||||||
|
| { status: "error" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shopping list section — routed at `/liste-de-courses`. Every ingredient
|
||||||
|
* line of every recipe planned for a selectable week, aggregated server-side
|
||||||
|
* (`GET /shopping-list`, see the API's `shopping-list.service.ts`) into one
|
||||||
|
* quantity per (ingredient, unit) pair, grouped by supermarket aisle for
|
||||||
|
* display. Deliberately simple by design — a read-only list, no
|
||||||
|
* checkboxes/crossing-off state: the source of truth for what's needed is
|
||||||
|
* the planning itself, not a separate to-do list this page would have to
|
||||||
|
* keep in sync with it.
|
||||||
|
*/
|
||||||
export function ShoppingListPage() {
|
export function ShoppingListPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
|
||||||
|
const [state, setState] = useState<ShoppingListState>({ status: "loading" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setState({ status: "loading" });
|
||||||
|
|
||||||
|
apiClient
|
||||||
|
.getShoppingListForWeek(formatDateOnly(weekStart))
|
||||||
|
.then((list) => {
|
||||||
|
if (!cancelled) setState({ status: "loaded", list });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setState({ status: "error" });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [weekStart]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComingSoonPage title={t("shoppingList.title")} description={t("shoppingList.comingSoon")} />
|
<div className="shopping-list-page">
|
||||||
|
<div className="shopping-list-page__header">
|
||||||
|
<h1>{t("shoppingList.title")}</h1>
|
||||||
|
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.status === "loading" && (
|
||||||
|
<p className="shopping-list-page__status">{t("shoppingList.loading")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "error" && (
|
||||||
|
<p className="shopping-list-page__status shopping-list-page__status--error">
|
||||||
|
{t("common.loadError")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "loaded" && <ShoppingListItems items={state.list.items} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The list itself, grouped by aisle (see `groupShoppingListItems`) — or the empty-week message if nothing's planned. */
|
||||||
|
function ShoppingListItems({ items }: { items: ShoppingListItemView[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <p className="shopping-list-page__status">{t("shoppingList.empty")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = groupShoppingListItems(items, (item) =>
|
||||||
|
t(`catalog.ingredients.${item.ingredient.key}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shopping-list">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<section key={group.category} className="shopping-list__group">
|
||||||
|
<h2 className="shopping-list__group-title">
|
||||||
|
<CategoryIcon category={group.category} />
|
||||||
|
{t(`recipes.form.category.${group.category}`)}
|
||||||
|
</h2>
|
||||||
|
<ul className="shopping-list__items">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<li key={`${item.ingredient.id}-${item.unit.id}`} className="shopping-list__item">
|
||||||
|
<span className="shopping-list__item-icon" aria-hidden="true">
|
||||||
|
<IngredientTypeIcon icon={item.ingredient.icon} />
|
||||||
|
</span>
|
||||||
|
<span className="shopping-list__item-name">
|
||||||
|
{t(`catalog.ingredients.${item.ingredient.key}`)}
|
||||||
|
</span>
|
||||||
|
<span className="shopping-list__item-quantity">
|
||||||
|
{formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
102
apps/web/src/pages/shopping-list/shopping-list-page.scss
Normal file
102
apps/web/src/pages/shopping-list/shopping-list-page.scss
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
// =============================================================================
|
||||||
|
// Styles specific to ShoppingListPage — colocated next to
|
||||||
|
// ShoppingListPage.tsx since nothing else uses these classes. Same page
|
||||||
|
// shell/status conventions as planning-page.scss (`.planning-page__header`/
|
||||||
|
// `__status`), a simple grouped list rather than a grid below it.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.shopping-list-page {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status--error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The list itself, grouped by aisle --------------------------------------
|
||||||
|
.shopping-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-list__group-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin: 0 0 var(--space-sm);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-list__items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-list__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-quantity {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
apps/web/src/pages/shopping-list/shopping-list.ts
Normal file
64
apps/web/src/pages/shopping-list/shopping-list.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import {
|
||||||
|
INGREDIENT_CATEGORIES,
|
||||||
|
type IngredientCategory,
|
||||||
|
type ShoppingListItemView,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
|
||||||
|
/** One aisle's worth of shopping list lines — see {@link groupShoppingListItems}. */
|
||||||
|
export interface ShoppingListGroup {
|
||||||
|
category: IngredientCategory;
|
||||||
|
items: ShoppingListItemView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups `items` by their ingredient's supermarket-aisle category (the same
|
||||||
|
* `IngredientCategory` the recipe form's `IngredientPicker` already browses
|
||||||
|
* by, see `ingredient-icons.tsx`'s `CategoryIcon`), in the app's canonical
|
||||||
|
* `INGREDIENT_CATEGORIES` order — a shopping list read aisle-by-aisle is far
|
||||||
|
* more useful in-store than one flat list. Within a group, lines are sorted
|
||||||
|
* by `ingredientLabel` — the caller's *already-translated* display name for
|
||||||
|
* that line, not the untranslated English `key` — so alphabetical order
|
||||||
|
* reads correctly in French; kept as a parameter (rather than calling
|
||||||
|
* `useTranslation` in here) so this stays a pure function the component can
|
||||||
|
* unit test without mounting i18next, same "logic extracted from the .tsx"
|
||||||
|
* split as every other feature in this codebase.
|
||||||
|
*/
|
||||||
|
export function groupShoppingListItems(
|
||||||
|
items: ShoppingListItemView[],
|
||||||
|
ingredientLabel: (item: ShoppingListItemView) => string,
|
||||||
|
): ShoppingListGroup[] {
|
||||||
|
const byCategory = new Map<IngredientCategory, ShoppingListItemView[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const category = item.ingredient.category;
|
||||||
|
const group = byCategory.get(category);
|
||||||
|
if (group) {
|
||||||
|
group.push(item);
|
||||||
|
} else {
|
||||||
|
byCategory.set(category, [item]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: ShoppingListGroup[] = [];
|
||||||
|
for (const category of INGREDIENT_CATEGORIES) {
|
||||||
|
const groupItems = byCategory.get(category);
|
||||||
|
if (!groupItems) continue;
|
||||||
|
groups.push({
|
||||||
|
category,
|
||||||
|
items: [...groupItems].sort((a, b) =>
|
||||||
|
ingredientLabel(a).localeCompare(ingredientLabel(b), "fr"),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats an aggregated quantity for display — French grouping/decimal
|
||||||
|
* conventions, at most 2 decimals (e.g. `"1,5"`, `"250"`) so summing several
|
||||||
|
* recipes' quantities (`shopping-list.service.ts`'s `aggregateShoppingList`,
|
||||||
|
* floating-point addition) never surfaces a long trailing-digit artifact
|
||||||
|
* like `"149.99999999999997"`.
|
||||||
|
*/
|
||||||
|
export function formatShoppingListQuantity(quantity: number): string {
|
||||||
|
return quantity.toLocaleString("fr-FR", { maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
@ -27,9 +27,6 @@ services:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: apps/api/Dockerfile
|
dockerfile: apps/api/Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
|
|
@ -45,8 +42,92 @@ services:
|
||||||
# authenticated request 401s despite login succeeding. See its doc
|
# authenticated request 401s despite login succeeding. See its doc
|
||||||
# comment in apps/api/src/config/env.ts.
|
# comment in apps/api/src/config/env.ts.
|
||||||
COOKIE_SECURE: ${COOKIE_SECURE:-}
|
COOKIE_SECURE: ${COOKIE_SECURE:-}
|
||||||
|
# Shared with the `tech-step-llm-worker` service below — see
|
||||||
|
# requireInternalWorker's doc comment
|
||||||
|
# (apps/api/src/middlewares/require-internal-worker.ts). Unset by
|
||||||
|
# default: `/internal/tech-steps/*` fails closed rather than open
|
||||||
|
# for a deployment that doesn't run the worker at all.
|
||||||
|
INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-}
|
||||||
|
# Compose network service name, not localhost — same reasoning as
|
||||||
|
# DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback:
|
||||||
|
# tech-step-intent-service is a core dependency (see its own entry
|
||||||
|
# below), not an optional background job.
|
||||||
|
INTENT_SERVICE_BASE_URL: "http://tech-step-intent-service:8000"
|
||||||
|
INTENT_SERVICE_SECRET: ${INTENT_SERVICE_SECRET:?set INTENT_SERVICE_SECRET in .env}
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-3000}:3000"
|
- "${APP_PORT:-3000}:3000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
tech-step-intent-service:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# spaCy-based NER + intent classification microservice
|
||||||
|
# (services/tech-step-intent-service) — `app` delegates all tech-step
|
||||||
|
# detection to it over HTTP (see `IntentServiceClient`,
|
||||||
|
# apps/api/src/lib/recipe-matching/intent-service-client.ts). Unlike
|
||||||
|
# `tech-step-llm-worker` below, **not optional**: without it, `app` can no
|
||||||
|
# longer detect any cooking technique in a recipe step at all. No exposed
|
||||||
|
# port — reachable only from `app` on the compose network, nothing ever
|
||||||
|
# calls into it from outside.
|
||||||
|
tech-step-intent-service:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: services/tech-step-intent-service/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
INTENT_SERVICE_SECRET: ${INTENT_SERVICE_SECRET:?set INTENT_SERVICE_SECRET in .env}
|
||||||
|
healthcheck:
|
||||||
|
# No curl/wget in the python:3.12-slim base image — a one-line Python
|
||||||
|
# request is the healthcheck for a service that's already guaranteed
|
||||||
|
# to have Python (see this service's Dockerfile).
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"python",
|
||||||
|
"-c",
|
||||||
|
"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2)",
|
||||||
|
]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
# This service trains itself from scratch on every start (no model
|
||||||
|
# ever persisted to disk, see its own README) — `/health` only
|
||||||
|
# returns 200 once that's done, not just once the base spaCy models
|
||||||
|
# are loaded. Measured at ~540s (fr) / ~390s (en), ~930s combined,
|
||||||
|
# against the current ~74-technique corpus — each technique now has
|
||||||
|
# the *same* number of `utterances` per locale as every other
|
||||||
|
# (equalized to the corpus's own pre-existing max, 7/5 — see
|
||||||
|
# `training_data.py`'s own doc comment for why a flat, larger target
|
||||||
|
# like 20 was tried and reverted) — `start_period` generous enough
|
||||||
|
# that failing checks during that whole window never count against
|
||||||
|
# `retries` (which would otherwise flip this container to
|
||||||
|
# "unhealthy" mid-training, blocking `app`'s own `depends_on:
|
||||||
|
# condition: service_healthy` indefinitely).
|
||||||
|
start_period: 1200s
|
||||||
|
|
||||||
|
# Deliberately its own image, not built into `app`'s (see
|
||||||
|
# services/tech-step-llm-worker/Dockerfile's own doc comment) — a
|
||||||
|
# long-lived process with no exposed port (nothing ever calls *into* it,
|
||||||
|
# it only ever calls out to `app`). Optional: an `INTERNAL_WORKER_SECRET`-
|
||||||
|
# less deployment can omit this service entirely and `app` still runs
|
||||||
|
# fine, just without the offline audit/feedback-loop jobs.
|
||||||
|
tech-step-llm-worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: services/tech-step-llm-worker/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
environment:
|
||||||
|
API_BASE_URL: "http://app:3000"
|
||||||
|
INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:?set INTERNAL_WORKER_SECRET in .env to run this service}
|
||||||
|
TECH_STEP_WORKER_CRON: ${TECH_STEP_WORKER_CRON:-0 3 * * 0}
|
||||||
|
volumes:
|
||||||
|
# GGUF weights persist across restarts — see this service's own
|
||||||
|
# Dockerfile doc comment on its VOLUME declaration.
|
||||||
|
- tech_step_llm_worker_models:/worker/models
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
tech_step_llm_worker_models:
|
||||||
|
|
|
||||||
661
packages/shared/src/data/catalog-labels-fr.ts
Normal file
661
packages/shared/src/data/catalog-labels-fr.ts
Normal file
|
|
@ -0,0 +1,661 @@
|
||||||
|
/**
|
||||||
|
* French display/matching labels for the `Ingredient` reference catalog
|
||||||
|
* (`apps/api/src/db/reference-seed-data.ts`'s `INGREDIENT_GROUPS`), keyed
|
||||||
|
* by the same `Ingredient.key` used throughout the app — the French
|
||||||
|
* counterpart to {@link INGREDIENT_LABELS_EN} (catalog-labels-en.ts), used
|
||||||
|
* by `apps/api/src/lib/recipe-matching/ingredient-matcher.ts` to resolve
|
||||||
|
* free-text ingredient lines from French-language recipe sources (Marmiton,
|
||||||
|
* 750g, Manger Bouger) against our catalog.
|
||||||
|
*
|
||||||
|
* Deliberately **not** hand-authored from scratch: every value here is
|
||||||
|
* copied verbatim from `apps/web/src/locales/fr/translation.json`'s
|
||||||
|
* `catalog.ingredients` (the same French names already shown in the UI),
|
||||||
|
* not written fresh for matching purposes the way {@link INGREDIENT_LABELS_EN}
|
||||||
|
* was. That reuse is a deliberate trade-off, not an oversight: it guarantees
|
||||||
|
* every one of this catalog's ~550 ingredients gets *some* French matching
|
||||||
|
* label for free, at the cost of a few labels being phrased for display
|
||||||
|
* (a UI-friendly short name) rather than for how someone would actually
|
||||||
|
* write it in running recipe text — e.g. `vanillaBean`'s "Vanille (gousse)"
|
||||||
|
* won't match "1 gousse de vanille" (matching is ordered — see
|
||||||
|
* `matchIngredientName`'s own doc comment — and "gousse" comes first in
|
||||||
|
* real usage, not "vanille"), which is exactly what
|
||||||
|
* {@link INGREDIENT_LABEL_SYNONYMS_FR} below exists to patch up, entry by
|
||||||
|
* entry, as real mismatches like that one turn up — the ingredient still
|
||||||
|
* resolves correctly once a synonym in the natural word order is added, no
|
||||||
|
* change to the primary (display) label required.
|
||||||
|
*/
|
||||||
|
export const INGREDIENT_LABELS_FR: Record<string, string> = {
|
||||||
|
// Vegetables
|
||||||
|
tomato: "Tomate",
|
||||||
|
onion: "Oignon",
|
||||||
|
shallot: "Échalote",
|
||||||
|
garlic: "Ail",
|
||||||
|
carrot: "Carotte",
|
||||||
|
zucchini: "Courgette",
|
||||||
|
cucumber: "Concombre",
|
||||||
|
gherkins: "Cornichons",
|
||||||
|
bellPepper: "Poivron",
|
||||||
|
mushroom: "Champignon",
|
||||||
|
porcini: "Cèpes",
|
||||||
|
eggplant: "Aubergine",
|
||||||
|
broccoli: "Brocoli",
|
||||||
|
cauliflower: "Chou-fleur",
|
||||||
|
whiteCabbage: "Chou blanc",
|
||||||
|
redCabbage: "Chou rouge",
|
||||||
|
brusselsSprouts: "Chou de Bruxelles",
|
||||||
|
spinach: "Épinard",
|
||||||
|
swissChard: "Blette",
|
||||||
|
lettuce: "Salade",
|
||||||
|
arugula: "Roquette",
|
||||||
|
watercress: "Cresson",
|
||||||
|
leek: "Poireau",
|
||||||
|
celery: "Céleri",
|
||||||
|
radish: "Radis",
|
||||||
|
beetroot: "Betterave",
|
||||||
|
turnip: "Navet",
|
||||||
|
parsnip: "Panais",
|
||||||
|
greenBean: "Haricot vert",
|
||||||
|
pea: "Petit pois",
|
||||||
|
corn: "Maïs",
|
||||||
|
artichoke: "Artichaut",
|
||||||
|
fennel: "Fenouil",
|
||||||
|
endive: "Endive",
|
||||||
|
pumpkin: "Potiron",
|
||||||
|
butternutSquash: "Butternut",
|
||||||
|
asparagus: "Asperge",
|
||||||
|
avocado: "Avocat",
|
||||||
|
potato: "Pomme de terre",
|
||||||
|
sweetPotato: "Patate douce",
|
||||||
|
cherryTomato: "Tomates cerises",
|
||||||
|
bokChoy: "Pak-choï",
|
||||||
|
soybeanSprouts: "Germes de soja",
|
||||||
|
shiitake: "Shiitake",
|
||||||
|
daikon: "Daikon",
|
||||||
|
freshGreenChili: "Piment vert frais",
|
||||||
|
cardoon: "Cardon",
|
||||||
|
radicchio: "Chicorée rouge",
|
||||||
|
romanesco: "Chou romanesco",
|
||||||
|
kohlrabi: "Chou-rave",
|
||||||
|
napaCabbage: "Chou chinois",
|
||||||
|
celeriac: "Céleri-rave",
|
||||||
|
okra: "Gombo",
|
||||||
|
springOnion: "Oignon nouveau",
|
||||||
|
redKuriSquash: "Potimarron",
|
||||||
|
rutabaga: "Rutabaga",
|
||||||
|
samphire: "Salicorne",
|
||||||
|
salsify: "Salsifis",
|
||||||
|
lambsLettuce: "Mâche",
|
||||||
|
escarole: "Scarole",
|
||||||
|
// Fruits
|
||||||
|
lemon: "Citron",
|
||||||
|
lime: "Citron vert",
|
||||||
|
apple: "Pomme",
|
||||||
|
pear: "Poire",
|
||||||
|
banana: "Banane",
|
||||||
|
orange: "Orange",
|
||||||
|
clementine: "Clémentine",
|
||||||
|
grapefruit: "Pamplemousse",
|
||||||
|
strawberry: "Fraise",
|
||||||
|
raspberry: "Framboise",
|
||||||
|
blueberry: "Myrtille",
|
||||||
|
blackberry: "Mûre",
|
||||||
|
cherry: "Cerise",
|
||||||
|
apricot: "Abricot",
|
||||||
|
peach: "Pêche",
|
||||||
|
plum: "Prune",
|
||||||
|
grape: "Raisin",
|
||||||
|
melon: "Melon",
|
||||||
|
watermelon: "Pastèque",
|
||||||
|
pineapple: "Ananas",
|
||||||
|
mango: "Mangue",
|
||||||
|
kiwi: "Kiwi",
|
||||||
|
fig: "Figue",
|
||||||
|
date: "Datte",
|
||||||
|
lychee: "Litchi",
|
||||||
|
pomegranate: "Grenade",
|
||||||
|
rhubarb: "Rhubarbe",
|
||||||
|
quince: "Coing",
|
||||||
|
blackcurrant: "Cassis",
|
||||||
|
cranberry: "Canneberge",
|
||||||
|
redcurrant: "Groseille",
|
||||||
|
persimmon: "Kaki",
|
||||||
|
nectarine: "Nectarine",
|
||||||
|
tamarind: "Tamarin",
|
||||||
|
// Fresh herbs
|
||||||
|
basil: "Basilic",
|
||||||
|
parsley: "Persil",
|
||||||
|
thyme: "Thym",
|
||||||
|
rosemary: "Romarin",
|
||||||
|
bayLeaf: "Laurier",
|
||||||
|
chives: "Ciboulette",
|
||||||
|
freshCilantro: "Coriandre fraîche",
|
||||||
|
mint: "Menthe",
|
||||||
|
oregano: "Origan",
|
||||||
|
dill: "Aneth",
|
||||||
|
tarragon: "Estragon",
|
||||||
|
savory: "Sarriette",
|
||||||
|
marjoram: "Marjolaine",
|
||||||
|
sage: "Sauge",
|
||||||
|
chervil: "Cerfeuil",
|
||||||
|
ginger: "Gingembre",
|
||||||
|
lemongrass: "Citronnelle",
|
||||||
|
kaffirLime: "Combava",
|
||||||
|
// Meats
|
||||||
|
rabbit: "Lapin",
|
||||||
|
groundBeef: "Bœuf haché",
|
||||||
|
beefSteak: "Steak de bœuf",
|
||||||
|
beefRoast: "Rôti de bœuf",
|
||||||
|
vealCutlet: "Escalope de veau",
|
||||||
|
porkTenderloin: "Filet mignon de porc",
|
||||||
|
porkChop: "Côte de porc",
|
||||||
|
groundVeal: "Veau haché",
|
||||||
|
groundPork: "Porc haché",
|
||||||
|
groundLamb: "Agneau haché",
|
||||||
|
lamb: "Agneau",
|
||||||
|
legOfLamb: "Gigot d'agneau",
|
||||||
|
baconLardons: "Lardons",
|
||||||
|
bacon: "Bacon",
|
||||||
|
ham: "Jambon blanc",
|
||||||
|
curedHam: "Jambon cru",
|
||||||
|
sausage: "Saucisse",
|
||||||
|
chorizo: "Chorizo",
|
||||||
|
merguez: "Merguez",
|
||||||
|
prosciutto: "Prosciutto",
|
||||||
|
pancetta: "Pancetta",
|
||||||
|
mortadella: "Mortadelle",
|
||||||
|
salami: "Salami",
|
||||||
|
andouille: "Andouille",
|
||||||
|
andouillette: "Andouillette",
|
||||||
|
whitePudding: "Boudin blanc",
|
||||||
|
blackPudding: "Boudin noir",
|
||||||
|
cervelat: "Cervelas",
|
||||||
|
rillettes: "Rillettes",
|
||||||
|
dryCuredSausage: "Saucisson sec",
|
||||||
|
bayonneHam: "Jambon de Bayonne",
|
||||||
|
coppa: "Coppa",
|
||||||
|
rosetteSausage: "Rosette (saucisson)",
|
||||||
|
vealLiver: "Foie de veau",
|
||||||
|
vealKidneys: "Rognons de veau",
|
||||||
|
vealBrain: "Cervelle de veau",
|
||||||
|
vealSweetbread: "Ris de veau",
|
||||||
|
beefTongue: "Langue de bœuf",
|
||||||
|
tripe: "Tripes",
|
||||||
|
venison: "Cerf",
|
||||||
|
roeDeer: "Chevreuil",
|
||||||
|
wildBoar: "Sanglier",
|
||||||
|
horseMeat: "Cheval",
|
||||||
|
beefHeart: "Cœur de bœuf",
|
||||||
|
foieGras: "Foie gras",
|
||||||
|
beefMuzzle: "Museau de bœuf",
|
||||||
|
grisonsDriedBeef: "Viande des Grisons",
|
||||||
|
// Poultry
|
||||||
|
chicken: "Poulet",
|
||||||
|
groundChicken: "Poulet haché",
|
||||||
|
turkey: "Dinde",
|
||||||
|
groundTurkey: "Dinde hachée",
|
||||||
|
duck: "Canard",
|
||||||
|
duckBreast: "Magret de canard",
|
||||||
|
quail: "Caille",
|
||||||
|
guineaFowl: "Pintade",
|
||||||
|
goose: "Oie",
|
||||||
|
poultryLiver: "Foie de volaille",
|
||||||
|
capon: "Chapon",
|
||||||
|
pigeon: "Pigeon",
|
||||||
|
pheasant: "Faisan",
|
||||||
|
// Fish
|
||||||
|
salmon: "Saumon",
|
||||||
|
tuna: "Thon",
|
||||||
|
cod: "Cabillaud",
|
||||||
|
trout: "Truite",
|
||||||
|
sardine: "Sardine",
|
||||||
|
anchovy: "Anchois",
|
||||||
|
whiting: "Merlan",
|
||||||
|
surimi: "Surimi",
|
||||||
|
seaBass: "Bar (loup de mer)",
|
||||||
|
seaBream: "Dorade",
|
||||||
|
sole: "Sole",
|
||||||
|
turbot: "Turbot",
|
||||||
|
hake: "Merlu",
|
||||||
|
pollock: "Colin",
|
||||||
|
saithe: "Lieu noir",
|
||||||
|
haddock: "Églefin",
|
||||||
|
mackerel: "Maquereau",
|
||||||
|
herring: "Hareng",
|
||||||
|
redMullet: "Rouget",
|
||||||
|
skate: "Raie",
|
||||||
|
monkfish: "Lotte",
|
||||||
|
halibut: "Flétan",
|
||||||
|
swordfish: "Espadon",
|
||||||
|
carp: "Carpe",
|
||||||
|
pike: "Brochet",
|
||||||
|
perch: "Perche",
|
||||||
|
tilapia: "Tilapia",
|
||||||
|
pangasius: "Panga",
|
||||||
|
smokedSalmon: "Saumon fumé",
|
||||||
|
driedFish: "Poisson séché",
|
||||||
|
eel: "Anguille",
|
||||||
|
plaice: "Carrelet (ou plie)",
|
||||||
|
saltCod: "Morue",
|
||||||
|
lemonSole: "Limande",
|
||||||
|
scorpionfish: "Rascasse",
|
||||||
|
// Shellfish
|
||||||
|
shrimp: "Crevettes",
|
||||||
|
langoustine: "Langoustines",
|
||||||
|
lobster: "Homard",
|
||||||
|
crab: "Crabe",
|
||||||
|
spinyLobster: "Langouste",
|
||||||
|
mussels: "Moules",
|
||||||
|
oysters: "Huîtres",
|
||||||
|
scallops: "Saint-Jacques",
|
||||||
|
squid: "Calamar",
|
||||||
|
octopus: "Poulpe",
|
||||||
|
clams: "Palourdes",
|
||||||
|
whelks: "Bulots",
|
||||||
|
spiderCrab: "Araignée de mer",
|
||||||
|
periwinkle: "Bigorneau",
|
||||||
|
crayfish: "Écrevisse",
|
||||||
|
greyShrimp: "Crevette grise",
|
||||||
|
cockle: "Coque",
|
||||||
|
snail: "Escargot",
|
||||||
|
cuttlefish: "Seiche",
|
||||||
|
// Starches
|
||||||
|
semolina: "Semoule",
|
||||||
|
couscous: "Couscous",
|
||||||
|
bulgur: "Boulgour",
|
||||||
|
polenta: "Polenta",
|
||||||
|
quinoa: "Quinoa",
|
||||||
|
pasta: "Pâtes",
|
||||||
|
wholeWheatPasta: "Pâtes complètes",
|
||||||
|
rice: "Riz",
|
||||||
|
basmatiRice: "Riz basmati",
|
||||||
|
brownRice: "Riz complet",
|
||||||
|
oats: "Flocons d'avoine",
|
||||||
|
spaghetti: "Spaghetti",
|
||||||
|
penne: "Penne",
|
||||||
|
tagliatelle: "Tagliatelles",
|
||||||
|
lasagnaSheets: "Lasagnes (feuilles)",
|
||||||
|
gnocchi: "Gnocchi",
|
||||||
|
arborioRice: "Riz arborio",
|
||||||
|
riceNoodles: "Nouilles de riz",
|
||||||
|
udonNoodles: "Nouilles udon",
|
||||||
|
sobaNoodles: "Nouilles soba",
|
||||||
|
chineseNoodles: "Nouilles chinoises",
|
||||||
|
riceVermicelli: "Vermicelles de riz",
|
||||||
|
soyVermicelli: "Vermicelles de soja",
|
||||||
|
stickyRice: "Riz gluant",
|
||||||
|
sushiRice: "Riz à sushi",
|
||||||
|
jasmineRice: "Riz jasmin",
|
||||||
|
// Legumes
|
||||||
|
greenLentils: "Lentilles vertes",
|
||||||
|
redLentils: "Lentilles corail",
|
||||||
|
chickpeas: "Pois chiches",
|
||||||
|
whiteBeans: "Haricots blancs",
|
||||||
|
kidneyBeans: "Haricots rouges",
|
||||||
|
blackBeans: "Haricots noirs",
|
||||||
|
splitPeas: "Pois cassés",
|
||||||
|
favaBeans: "Fèves",
|
||||||
|
edamame: "Edamame",
|
||||||
|
pintoBeans: "Haricots pinto",
|
||||||
|
flageoletBeans: "Haricots flageolets",
|
||||||
|
goldenLentils: "Lentilles blondes",
|
||||||
|
// Nuts, seeds and other dry goods
|
||||||
|
peanutsShelled: "Cacahuètes",
|
||||||
|
almonds: "Amandes",
|
||||||
|
walnuts: "Noix",
|
||||||
|
hazelnuts: "Noisettes",
|
||||||
|
cashews: "Noix de cajou",
|
||||||
|
pistachios: "Pistaches",
|
||||||
|
pecans: "Noix de pécan",
|
||||||
|
almondPowder: "Poudre d'amande",
|
||||||
|
pineNuts: "Pignons de pin",
|
||||||
|
sunflowerSeeds: "Graines de tournesol",
|
||||||
|
pumpkinSeeds: "Graines de courge",
|
||||||
|
shreddedCoconut: "Noix de coco râpée",
|
||||||
|
raisins: "Raisins secs",
|
||||||
|
prunes: "Pruneaux",
|
||||||
|
driedApricots: "Abricots secs",
|
||||||
|
sesameSeeds: "Graines de sésame",
|
||||||
|
blackMushrooms: "Champignons noirs",
|
||||||
|
noriSeaweed: "Algue nori",
|
||||||
|
wakameSeaweed: "Algue wakamé",
|
||||||
|
kombuSeaweed: "Algue kombu",
|
||||||
|
bambooShoots: "Pousses de bambou",
|
||||||
|
waterChestnuts: "Châtaignes d'eau",
|
||||||
|
// Breads
|
||||||
|
bread: "Pain",
|
||||||
|
sandwichBread: "Pain de mie",
|
||||||
|
wholeWheatBread: "Pain complet",
|
||||||
|
baguette: "Baguette",
|
||||||
|
ryeBread: "Pain de seigle",
|
||||||
|
breadcrumbs: "Chapelure",
|
||||||
|
burgerBun: "Pain à burger",
|
||||||
|
briocheBun: "Pain brioché",
|
||||||
|
hotDogBun: "Pain à hot-dog",
|
||||||
|
pitaBread: "Pain pita",
|
||||||
|
bagel: "Pain bagel",
|
||||||
|
naan: "Naan",
|
||||||
|
wrapBread: "Pain wrap",
|
||||||
|
vienneseBread: "Pain viennois",
|
||||||
|
countryBread: "Pain de campagne",
|
||||||
|
multigrainBread: "Pain aux céréales",
|
||||||
|
breadRoll: "Petit pain",
|
||||||
|
swedishBread: "Pain suédois",
|
||||||
|
glutenFreeBread: "Pain sans gluten",
|
||||||
|
rusk: "Biscotte",
|
||||||
|
croutons: "Croûtons",
|
||||||
|
focaccia: "Focaccia",
|
||||||
|
ciabatta: "Ciabatta",
|
||||||
|
cornTortilla: "Tortilla de maïs",
|
||||||
|
wheatTortilla: "Tortilla de blé",
|
||||||
|
breadstick: "Gressin",
|
||||||
|
// Raw dough
|
||||||
|
puffPastry: "Pâte feuilletée",
|
||||||
|
shortcrustPastry: "Pâte brisée",
|
||||||
|
pizzaDough: "Pâte à pizza",
|
||||||
|
sweetShortcrustPastry: "Pâte à tarte sablée",
|
||||||
|
// Dairy
|
||||||
|
milk: "Lait",
|
||||||
|
butter: "Beurre",
|
||||||
|
cremeFraiche: "Crème fraîche",
|
||||||
|
liquidCream: "Crème liquide",
|
||||||
|
cheese: "Fromage",
|
||||||
|
emmental: "Emmental",
|
||||||
|
gruyere: "Gruyère",
|
||||||
|
parmesan: "Parmesan",
|
||||||
|
mozzarella: "Mozzarella",
|
||||||
|
goatCheese: "Chèvre (fromage)",
|
||||||
|
feta: "Feta",
|
||||||
|
comte: "Comté",
|
||||||
|
fromageBlanc: "Fromage blanc",
|
||||||
|
mascarpone: "Mascarpone",
|
||||||
|
yogurt: "Yaourt",
|
||||||
|
burrata: "Burrata",
|
||||||
|
ricotta: "Ricotta",
|
||||||
|
pecorino: "Pecorino",
|
||||||
|
gorgonzola: "Gorgonzola",
|
||||||
|
cheddar: "Cheddar",
|
||||||
|
brie: "Brie",
|
||||||
|
camembert: "Camembert",
|
||||||
|
roquefort: "Roquefort",
|
||||||
|
munster: "Munster",
|
||||||
|
reblochon: "Reblochon",
|
||||||
|
cantal: "Cantal",
|
||||||
|
beaufort: "Beaufort",
|
||||||
|
saintNectaire: "Saint-Nectaire",
|
||||||
|
blueCheese: "Bleu (fromage)",
|
||||||
|
cancoillotte: "Cancoillotte",
|
||||||
|
tomme: "Tomme",
|
||||||
|
epoisses: "Époisses",
|
||||||
|
chaource: "Chaource",
|
||||||
|
livarot: "Livarot",
|
||||||
|
pontLeveque: "Pont-l'Évêque",
|
||||||
|
morbier: "Morbier",
|
||||||
|
racletteCheese: "Raclette (fromage)",
|
||||||
|
fourmeDAmbert: "Fourme d'Ambert",
|
||||||
|
salers: "Salers",
|
||||||
|
ossauIraty: "Ossau-Iraty",
|
||||||
|
vacherin: "Vacherin",
|
||||||
|
saintMarcellin: "Saint-Marcellin",
|
||||||
|
neufchatel: "Neufchâtel",
|
||||||
|
crottinDeChavignol: "Crottin de Chavignol",
|
||||||
|
abondanceCheese: "Abondance",
|
||||||
|
carreDeLEst: "Carré de l'Est",
|
||||||
|
edam: "Edam",
|
||||||
|
gouda: "Gouda",
|
||||||
|
mimolette: "Mimolette",
|
||||||
|
maroilles: "Maroilles",
|
||||||
|
montDor: "Mont d'or",
|
||||||
|
kefir: "Kéfir",
|
||||||
|
greekYogurt: "Yaourt à la grecque",
|
||||||
|
// Eggs
|
||||||
|
egg: "Oeuf",
|
||||||
|
eggYolk: "Jaune d'oeuf",
|
||||||
|
eggWhite: "Blanc d'oeuf",
|
||||||
|
// Plant-based alternatives
|
||||||
|
coconutMilk: "Lait de coco",
|
||||||
|
coconutCream: "Crème de coco",
|
||||||
|
almondMilk: "Lait d'amande",
|
||||||
|
oatMilk: "Lait d'avoine",
|
||||||
|
tofu: "Tofu",
|
||||||
|
silkenTofu: "Tofu soyeux",
|
||||||
|
// Spices
|
||||||
|
herbesDeProvence: "Herbes de Provence",
|
||||||
|
blackPepper: "Poivre noir",
|
||||||
|
paprika: "Paprika",
|
||||||
|
espelettePepper: "Piment d'Espelette",
|
||||||
|
cayennePepper: "Piment de Cayenne",
|
||||||
|
cumin: "Cumin",
|
||||||
|
curryPowder: "Curry (poudre)",
|
||||||
|
turmeric: "Curcuma",
|
||||||
|
cinnamon: "Cannelle",
|
||||||
|
nutmeg: "Muscade",
|
||||||
|
saffron: "Safran",
|
||||||
|
clove: "Clou de girofle",
|
||||||
|
vanillaBean: "Vanille (gousse)",
|
||||||
|
whitePepper: "Poivre blanc",
|
||||||
|
pinkPepper: "Poivre rose",
|
||||||
|
sichuanPepper: "Poivre du Sichuan",
|
||||||
|
smokedPaprika: "Paprika fumé",
|
||||||
|
birdEyeChili: "Piment oiseau",
|
||||||
|
juniperBerries: "Baies de genièvre",
|
||||||
|
starAnise: "Anis étoilé (badiane)",
|
||||||
|
greenAnise: "Anis vert",
|
||||||
|
fennelSeeds: "Graines de fenouil",
|
||||||
|
sumac: "Sumac",
|
||||||
|
nigella: "Nigelle",
|
||||||
|
allspice: "Quatre épices",
|
||||||
|
colomboPowder: "Colombo (poudre)",
|
||||||
|
baharat: "Baharat",
|
||||||
|
horseradish: "Raifort",
|
||||||
|
herbSalt: "Sel aux herbes",
|
||||||
|
celerySalt: "Sel de céleri",
|
||||||
|
fleurDeSel: "Fleur de sel",
|
||||||
|
salt: "Sel",
|
||||||
|
fiveSpice: "Cinq épices",
|
||||||
|
garamMasala: "Garam masala",
|
||||||
|
corianderSeeds: "Graines de coriandre",
|
||||||
|
groundCoriander: "Coriandre en poudre",
|
||||||
|
cardamom: "Cardamome",
|
||||||
|
fenugreek: "Fenugrec",
|
||||||
|
jalapeno: "Piment jalapeño",
|
||||||
|
chipotle: "Piment chipotle",
|
||||||
|
poblanoPepper: "Piment poblano",
|
||||||
|
habanero: "Piment habanero",
|
||||||
|
rasElHanout: "Ras el hanout",
|
||||||
|
zaatar: "Za'atar",
|
||||||
|
// Sauces
|
||||||
|
soySauce: "Sauce soja",
|
||||||
|
mustard: "Moutarde",
|
||||||
|
mayonnaise: "Mayonnaise",
|
||||||
|
ketchup: "Ketchup",
|
||||||
|
tabasco: "Tabasco",
|
||||||
|
worcestershireSauce: "Sauce Worcestershire",
|
||||||
|
fishSauce: "Sauce nuoc-mâm",
|
||||||
|
wasabi: "Wasabi",
|
||||||
|
harissa: "Harissa",
|
||||||
|
curryPaste: "Pâte de curry",
|
||||||
|
peanutButter: "Beurre de cacahuète",
|
||||||
|
dijonMustard: "Moutarde de Dijon",
|
||||||
|
wholegrainMustard: "Moutarde à l'ancienne",
|
||||||
|
barbecueSauce: "Sauce barbecue",
|
||||||
|
tartarSauce: "Sauce tartare",
|
||||||
|
cocktailSauce: "Sauce cocktail",
|
||||||
|
bearnaiseSauce: "Sauce béarnaise",
|
||||||
|
hollandaiseSauce: "Sauce hollandaise",
|
||||||
|
bechamelSauce: "Sauce béchamel",
|
||||||
|
teriyakiSauce: "Sauce teriyaki",
|
||||||
|
ponzuSauce: "Sauce ponzu",
|
||||||
|
chimichurri: "Chimichurri",
|
||||||
|
redPesto: "Pesto rouge (tomates séchées)",
|
||||||
|
pesto: "Pesto",
|
||||||
|
oysterSauce: "Sauce huître",
|
||||||
|
hoisinSauce: "Sauce hoisin",
|
||||||
|
sriracha: "Sauce sriracha",
|
||||||
|
sweetChiliSauce: "Sauce sweet chili",
|
||||||
|
miso: "Miso",
|
||||||
|
shrimpPaste: "Pâte de crevettes",
|
||||||
|
redCurryPaste: "Pâte de curry rouge (thaï)",
|
||||||
|
greenCurryPaste: "Pâte de curry vert (thaï)",
|
||||||
|
tahini: "Tahini",
|
||||||
|
aioli: "Aïoli",
|
||||||
|
vinaigrette: "Sauce vinaigrette",
|
||||||
|
hummus: "Houmous",
|
||||||
|
// Seasonings — oils, vinegars, wines and other flavorings
|
||||||
|
oliveOil: "Huile d'olive",
|
||||||
|
sunflowerOil: "Huile de tournesol",
|
||||||
|
rapeseedOil: "Huile de colza",
|
||||||
|
coconutOil: "Huile de coco",
|
||||||
|
sesameOil: "Huile de sésame",
|
||||||
|
ciderVinegar: "Vinaigre de cidre",
|
||||||
|
whiteVinegar: "Vinaigre blanc",
|
||||||
|
balsamicVinegar: "Vinaigre balsamique",
|
||||||
|
capers: "Câpres",
|
||||||
|
olives: "Olives",
|
||||||
|
blackOlives: "Olives noires",
|
||||||
|
greenOlives: "Olives vertes",
|
||||||
|
whiteWine: "Vin blanc (cuisine)",
|
||||||
|
redWine: "Vin rouge (cuisine)",
|
||||||
|
roseWine: "Vin rosé (cuisine)",
|
||||||
|
redWineVinegar: "Vinaigre de vin rouge",
|
||||||
|
whiteWineVinegar: "Vinaigre de vin blanc",
|
||||||
|
sherryVinegar: "Vinaigre de xérès",
|
||||||
|
walnutOil: "Huile de noix",
|
||||||
|
hazelnutOil: "Huile de noisette",
|
||||||
|
peanutOil: "Huile d'arachide",
|
||||||
|
chiliOil: "Huile pimentée",
|
||||||
|
riceVinegar: "Vinaigre de riz",
|
||||||
|
cornOil: "Huile de maïs",
|
||||||
|
grapeseedOil: "Huile de pépins de raisin",
|
||||||
|
soybeanOil: "Huile de soja",
|
||||||
|
palmOil: "Huile de palme",
|
||||||
|
mirin: "Mirin",
|
||||||
|
sake: "Saké (cuisine)",
|
||||||
|
lemonJuice: "Jus de citron",
|
||||||
|
limeJuice: "Jus de citron vert",
|
||||||
|
orangeJuice: "Jus d'orange",
|
||||||
|
appleJuice: "Jus de pomme",
|
||||||
|
grapeJuice: "Jus de raisin",
|
||||||
|
tomatoJuice: "Jus de tomate",
|
||||||
|
cranberryJuice: "Jus de cranberry",
|
||||||
|
coffee: "Café",
|
||||||
|
tea: "Thé",
|
||||||
|
beer: "Bière (cuisine)",
|
||||||
|
cider: "Cidre (cuisine)",
|
||||||
|
champagne: "Champagne / vin pétillant (cuisine)",
|
||||||
|
portWine: "Porto (cuisine)",
|
||||||
|
vinJaune: "Vin jaune (cuisine)",
|
||||||
|
cognac: "Cognac",
|
||||||
|
rum: "Rhum",
|
||||||
|
whisky: "Whisky",
|
||||||
|
vodka: "Vodka",
|
||||||
|
// Bases — flours, stocks and other cooking essentials
|
||||||
|
wheatFlour: "Farine de blé",
|
||||||
|
wholeWheatFlour: "Farine complète",
|
||||||
|
cornFlour: "Farine de maïs",
|
||||||
|
buckwheatFlour: "Farine de sarrasin",
|
||||||
|
riceFlour: "Farine de riz",
|
||||||
|
vegetableStockCube: "Bouillon cube légumes",
|
||||||
|
chickenStockCube: "Bouillon cube volaille",
|
||||||
|
tomatoPaste: "Concentré de tomate",
|
||||||
|
tomatoCoulis: "Coulis de tomate",
|
||||||
|
cannedPeeledTomatoes: "Tomates pelées (conserve)",
|
||||||
|
sunDriedTomatoes: "Tomates séchées",
|
||||||
|
vealStock: "Fond de veau",
|
||||||
|
chickenStock: "Fond de volaille",
|
||||||
|
beefStockCube: "Bouillon cube bœuf",
|
||||||
|
fishStockCube: "Bouillon cube poisson",
|
||||||
|
vegetableBroth: "Bouillon de légumes",
|
||||||
|
chickenBroth: "Bouillon de volaille",
|
||||||
|
beefBroth: "Bouillon de bœuf",
|
||||||
|
courtBouillon: "Court-bouillon",
|
||||||
|
dashi: "Dashi (bouillon japonais)",
|
||||||
|
shellfishBisque: "Bisque de crustacés",
|
||||||
|
tapiocaFlour: "Farine de tapioca",
|
||||||
|
masaHarina: "Masa harina",
|
||||||
|
water: "Eau",
|
||||||
|
sparklingWater: "Eau gazeuse",
|
||||||
|
orangeBlossomWater: "Eau de fleur d'oranger",
|
||||||
|
roseWater: "Eau de rose",
|
||||||
|
fishFumet: "Fumet de poisson",
|
||||||
|
// Thickeners and raising agents
|
||||||
|
bakersYeast: "Levure boulangère",
|
||||||
|
bakingPowder: "Levure chimique",
|
||||||
|
cornstarch: "Maïzena",
|
||||||
|
lupinFlour: "Farine de lupin",
|
||||||
|
gelatin: "Gélatine",
|
||||||
|
bakingSoda: "Bicarbonate de soude",
|
||||||
|
potatoStarch: "Fécule de pomme de terre",
|
||||||
|
// Sugars
|
||||||
|
sugar: "Sucre",
|
||||||
|
honey: "Miel",
|
||||||
|
mapleSyrup: "Sirop d'érable",
|
||||||
|
brownSugar: "Sucre roux",
|
||||||
|
powderedSugar: "Sucre glace",
|
||||||
|
demeraraSugar: "Cassonade",
|
||||||
|
darkChocolate: "Chocolat noir",
|
||||||
|
milkChocolate: "Chocolat au lait",
|
||||||
|
whiteChocolate: "Chocolat blanc",
|
||||||
|
chocolateChips: "Pépites de chocolat",
|
||||||
|
cocoaPowder: "Cacao en poudre",
|
||||||
|
vanillaExtract: "Extrait de vanille",
|
||||||
|
palmSugar: "Sucre de palme",
|
||||||
|
caneSyrup: "Sirop de sucre de canne",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extra French matching phrases for a handful of {@link INGREDIENT_LABELS_FR}
|
||||||
|
* entries whose primary (display) label doesn't match how the phrase is
|
||||||
|
* actually written in running recipe text — see that constant's own doc
|
||||||
|
* comment for why this is expected to grow over time, the same "exceptions
|
||||||
|
* only, not every entry" shape as {@link INGREDIENT_LABEL_SYNONYMS_EN}.
|
||||||
|
*/
|
||||||
|
export const INGREDIENT_LABEL_SYNONYMS_FR: Record<string, string[]> = {
|
||||||
|
// "Vanille (gousse)" is display-first-word-last; real recipe text says
|
||||||
|
// "gousse de vanille" (pod word first) — see INGREDIENT_LABELS_FR's doc
|
||||||
|
// comment.
|
||||||
|
vanillaBean: ["Gousse de vanille"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* French matching synonyms for the `Unit` reference catalog
|
||||||
|
* (`apps/api/src/db/reference-seed-data.ts`'s `UNITS`), keyed by
|
||||||
|
* `Unit.key` — the French counterpart to {@link UNIT_LABELS_EN}. Unlike
|
||||||
|
* {@link INGREDIENT_LABELS_FR}, these are **not** copied from
|
||||||
|
* `apps/web`'s locale file: that file has exactly one display label per
|
||||||
|
* unit (`apps/web/src/locales/fr/translation.json`'s `catalog.units`,
|
||||||
|
* e.g. `tablespoon`: "cuillère à soupe"), fine for a dropdown but not
|
||||||
|
* enough to *match* French recipe text against — real recipes freely mix
|
||||||
|
* the full phrase, its plural, and common abbreviations ("cuillère à
|
||||||
|
* soupe", "cuillères à soupe", "c. à soupe", "càs" all appear in the
|
||||||
|
* wild), so each entry here is hand-authored the same way
|
||||||
|
* {@link UNIT_LABELS_EN} was, starting from that same display label.
|
||||||
|
*
|
||||||
|
* Several of these are genuinely multi-word ("cuillère à soupe") — unlike
|
||||||
|
* English units, which are always a single word/abbreviation. Matching a
|
||||||
|
* multi-word unit needs the same ordered-contiguous-run search
|
||||||
|
* `matchIngredientName` already does for ingredients, not the older
|
||||||
|
* single-first-word check `matchUnit` used before French support existed
|
||||||
|
* — see `matchUnit`'s own doc comment (`ingredient-matcher.ts`) for the
|
||||||
|
* bug that would otherwise cause: a multi-word French synonym's *whole
|
||||||
|
* phrase* (spaces and all) would never equal a single extracted word, so
|
||||||
|
* it could never match anything at all.
|
||||||
|
*/
|
||||||
|
export const UNIT_LABELS_FR: Record<string, string[]> = {
|
||||||
|
gram: ["g", "gr", "gramme", "grammes"],
|
||||||
|
kilogram: ["kg", "kilo", "kilos", "kilogramme", "kilogrammes"],
|
||||||
|
milliliter: ["ml", "millilitre", "millilitres"],
|
||||||
|
centiliter: ["cl", "centilitre", "centilitres"],
|
||||||
|
liter: ["l", "litre", "litres"],
|
||||||
|
tablespoon: ["cuillère à soupe", "cuillères à soupe", "c. à soupe", "c à soupe", "cas", "càs"],
|
||||||
|
teaspoon: ["cuillère à café", "cuillères à café", "c. à café", "c à café", "cac", "càc"],
|
||||||
|
piece: ["unité", "unités", "pièce", "pièces"],
|
||||||
|
pinch: ["pincée", "pincées"],
|
||||||
|
slice: ["tranche", "tranches"],
|
||||||
|
clove: ["gousse", "gousses"],
|
||||||
|
bunch: ["botte", "bottes"],
|
||||||
|
sachet: ["sachet", "sachets"],
|
||||||
|
sprig: ["brin", "brins"],
|
||||||
|
cup: ["tasse", "tasses"],
|
||||||
|
ounce: ["once", "onces"],
|
||||||
|
pound: ["livre", "livres"],
|
||||||
|
};
|
||||||
|
|
@ -64,6 +64,14 @@ export enum ErrorCode {
|
||||||
UNIT_NOT_FOUND = 4048,
|
UNIT_NOT_FOUND = 4048,
|
||||||
/** `PATCH /house/current/sources`'s `sourceIds` contains one that doesn't match any reference `Source` row. */
|
/** `PATCH /house/current/sources`'s `sourceIds` contains one that doesn't match any reference `Source` row. */
|
||||||
SOURCE_NOT_FOUND = 4049,
|
SOURCE_NOT_FOUND = 4049,
|
||||||
|
/** `POST /recipes/:id/steps/:stepId/corrections` given a `stepId` that doesn't belong to a recipe visible to the caller. */
|
||||||
|
STEP_NOT_FOUND = 4050,
|
||||||
|
/** A tech-step correction's `previousTechStepId`/`correctedTechStepId` doesn't match any reference `TechStep` row. */
|
||||||
|
TECH_STEP_NOT_FOUND = 4051,
|
||||||
|
/** A tech-step correction's manually-attached `utensils[].utensilId` doesn't match any reference `Utensil` row. */
|
||||||
|
UTENSIL_NOT_FOUND = 4052,
|
||||||
|
/** A tech-step correction's `start`/`end` span falls outside the target step's `description`, or `start >= end`. */
|
||||||
|
INVALID_CORRECTION_SPAN = 4002,
|
||||||
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
||||||
INTERNAL_ERROR = 5000,
|
INTERNAL_ERROR = 5000,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
// detail specific to one side.
|
// detail specific to one side.
|
||||||
|
|
||||||
export * from "./data/catalog-labels-en.js";
|
export * from "./data/catalog-labels-en.js";
|
||||||
|
export * from "./data/catalog-labels-fr.js";
|
||||||
export * from "./errors/error-codes.js";
|
export * from "./errors/error-codes.js";
|
||||||
export * from "./schemas/account.js";
|
export * from "./schemas/account.js";
|
||||||
export * from "./schemas/auth.js";
|
export * from "./schemas/auth.js";
|
||||||
|
|
@ -12,12 +13,16 @@ export * from "./schemas/planning.js";
|
||||||
export * from "./schemas/preferences.js";
|
export * from "./schemas/preferences.js";
|
||||||
export * from "./schemas/profile.js";
|
export * from "./schemas/profile.js";
|
||||||
export * from "./schemas/recipe.js";
|
export * from "./schemas/recipe.js";
|
||||||
|
export * from "./schemas/shopping-list.js";
|
||||||
export * from "./schemas/sources.js";
|
export * from "./schemas/sources.js";
|
||||||
|
export * from "./schemas/tech-step-worker.js";
|
||||||
export * from "./tools/assert-is-never.js";
|
export * from "./tools/assert-is-never.js";
|
||||||
export * from "./types/household.js";
|
export * from "./types/household.js";
|
||||||
export * from "./types/planning.js";
|
export * from "./types/planning.js";
|
||||||
export * from "./types/preferences.js";
|
export * from "./types/preferences.js";
|
||||||
export * from "./types/recipe.js";
|
export * from "./types/recipe.js";
|
||||||
export * from "./types/reference.js";
|
export * from "./types/reference.js";
|
||||||
|
export * from "./types/shopping-list.js";
|
||||||
export * from "./types/sources.js";
|
export * from "./types/sources.js";
|
||||||
|
export * from "./types/tech-step-worker.js";
|
||||||
export * from "./types/user-profile.js";
|
export * from "./types/user-profile.js";
|
||||||
|
|
|
||||||
|
|
@ -116,3 +116,97 @@ export const listRecipesSchema = z.object({
|
||||||
});
|
});
|
||||||
/** Inferred TS type for {@link listRecipesSchema}'s validated output. */
|
/** Inferred TS type for {@link listRecipesSchema}'s validated output. */
|
||||||
export type ListRecipesInput = z.infer<typeof listRecipesSchema>;
|
export type ListRecipesInput = z.infer<typeof listRecipesSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One ingredient mention the user themselves points at while correcting a
|
||||||
|
* technique — `start`/`end` is *their own* selection of the exact passage
|
||||||
|
* of `description` that names it (a separate selection from the
|
||||||
|
* correction's own `[start, end)`, see `TechStepCorrectionPopover.tsx`),
|
||||||
|
* not derived from anything the classifier found. `quantity`/`unitId`
|
||||||
|
* are optional — a mention with no quantity attached ("ajouter le sel")
|
||||||
|
* is still worth recording. See `submitTechStepCorrectionSchema`'s own
|
||||||
|
* doc comment for how `ingredients` as a whole behaves.
|
||||||
|
*/
|
||||||
|
const manualStepTechStepIngredientInputSchema = z
|
||||||
|
.object({
|
||||||
|
ingredientId: z.number().int().positive(),
|
||||||
|
quantity: z.number().positive("La quantité doit être positive").nullable().optional(),
|
||||||
|
unitId: z.number().int().positive().nullable().optional(),
|
||||||
|
start: z.number().int().nonnegative(),
|
||||||
|
end: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
.refine((ingredient) => ingredient.end > ingredient.start, {
|
||||||
|
message: "end must be greater than start",
|
||||||
|
path: ["end"],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A utensil mention the user points at while correcting a technique — same `start`/`end` convention as {@link manualStepTechStepIngredientInputSchema}, no quantity/unit (nothing to measure for a utensil). */
|
||||||
|
const manualStepTechStepUtensilInputSchema = z
|
||||||
|
.object({
|
||||||
|
utensilId: z.number().int().positive(),
|
||||||
|
start: z.number().int().nonnegative(),
|
||||||
|
end: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
.refine((utensil) => utensil.end > utensil.start, {
|
||||||
|
message: "end must be greater than start",
|
||||||
|
path: ["end"],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload accepted by `POST /recipes/:id/steps/:stepId/corrections` — a
|
||||||
|
* user asserting what technique a `[start, end)` span of a step's
|
||||||
|
* `description` should (or shouldn't) be tagged with. `previousTechStepId`
|
||||||
|
* is the existing match being corrected (omit/`null` when the user is
|
||||||
|
* flagging a technique the classifier missed entirely — nothing to
|
||||||
|
* correct, just to add); `correctedTechStepId` is what they assert instead
|
||||||
|
* (omit/`null` means "no technique belongs here", i.e. removing a wrong
|
||||||
|
* match). Rejecting both being absent at once happens service-side
|
||||||
|
* (`recipe-tech-step-correction.service.ts`) — needs the target step's
|
||||||
|
* `description` length to validate `start`/`end` against, which this shape
|
||||||
|
* alone can't see.
|
||||||
|
*
|
||||||
|
* `ingredients`/`utensils` let the user attach metadata to the technique
|
||||||
|
* they're asserting (`correctedTechStepId`), same `source: "manual"`
|
||||||
|
* distinction the technique itself gets. **Omitted (`undefined`) means
|
||||||
|
* "leave whatever metadata already exists on this occurrence alone" —
|
||||||
|
* an explicit array, even `[]`, means "this is now the complete set,
|
||||||
|
* replace everything that was there" (auto-detected included; see
|
||||||
|
* `applyManualCorrection`'s own doc comment). This is why neither field
|
||||||
|
* has a `.default([])`: that would silently turn every plain relabel into
|
||||||
|
* a metadata wipe.** Only meaningful alongside a real `correctedTechStepId`
|
||||||
|
* — enforced by this schema's own refine below, since there's no live
|
||||||
|
* `StepTechStep` row to attach to otherwise (removing a match, or a
|
||||||
|
* request with neither id set).
|
||||||
|
*/
|
||||||
|
export const submitTechStepCorrectionSchema = z
|
||||||
|
.object({
|
||||||
|
start: z.number().int().nonnegative(),
|
||||||
|
end: z.number().int().nonnegative(),
|
||||||
|
previousTechStepId: z.number().int().positive().nullable().optional(),
|
||||||
|
correctedTechStepId: z.number().int().positive().nullable().optional(),
|
||||||
|
ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(),
|
||||||
|
utensils: z.array(manualStepTechStepUtensilInputSchema).optional(),
|
||||||
|
})
|
||||||
|
.refine((input) => input.end > input.start, {
|
||||||
|
message: "end must be greater than start",
|
||||||
|
path: ["end"],
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(input) =>
|
||||||
|
(input.previousTechStepId ?? null) !== null || (input.correctedTechStepId ?? null) !== null,
|
||||||
|
{
|
||||||
|
message: "at least one of previousTechStepId/correctedTechStepId is required",
|
||||||
|
path: ["correctedTechStepId"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(input) =>
|
||||||
|
(input.ingredients === undefined && input.utensils === undefined) ||
|
||||||
|
(input.correctedTechStepId ?? null) !== null,
|
||||||
|
{
|
||||||
|
message: "ingredients/utensils require a correctedTechStepId to attach to",
|
||||||
|
path: ["correctedTechStepId"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
/** Inferred TS type for {@link submitTechStepCorrectionSchema}'s validated output. */
|
||||||
|
export type SubmitTechStepCorrectionInput = z.infer<typeof submitTechStepCorrectionSchema>;
|
||||||
|
|
|
||||||
19
packages/shared/src/schemas/shopping-list.ts
Normal file
19
packages/shared/src/schemas/shopping-list.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// See schemas/auth.ts for the shared client/server validation rationale.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload accepted by `GET /shopping-list`'s `?date=` query param — same
|
||||||
|
* shape/rationale as `schemas/planning.ts`'s `getPlanningByDateSchema`
|
||||||
|
* (only checks the `YYYY-MM-DD` shape, real-calendar-date validation is
|
||||||
|
* service-side via `@batch-cooking/date-tools`'s `parseDateOnly`). Kept as
|
||||||
|
* its own schema rather than importing `getPlanningByDateSchema` — each
|
||||||
|
* router module owns its own request contract in this repo, even when two
|
||||||
|
* happen to share a shape (see the two near-identical `date` fields already
|
||||||
|
* inside `schemas/planning.ts` itself).
|
||||||
|
*/
|
||||||
|
export const getShoppingListSchema = z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date invalide"),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link getShoppingListSchema}'s validated output. */
|
||||||
|
export type GetShoppingListInput = z.infer<typeof getShoppingListSchema>;
|
||||||
54
packages/shared/src/schemas/tech-step-worker.ts
Normal file
54
packages/shared/src/schemas/tech-step-worker.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/** Query params accepted by `GET /internal/tech-steps/audit-batch` and `GET /internal/tech-steps/pending-corrections` — both just a bound on how much work one call asks for, so the worker controls its own batch size rather than the server guessing. */
|
||||||
|
export const workerBatchQuerySchema = z.object({
|
||||||
|
limit: z.coerce.number().int().positive().max(500).default(50),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link workerBatchQuerySchema}'s validated output. */
|
||||||
|
export type WorkerBatchQueryInput = z.infer<typeof workerBatchQuerySchema>;
|
||||||
|
|
||||||
|
/** `locale` param `GET /internal/tech-steps/audit-batch` also accepts, on top of {@link workerBatchQuerySchema} — which of `TECH_STEP_TRAINING_DATA`'s locales to sample steps' clauses against (see `tech-step-matcher.ts`'s `matchTechStepSpans` for the same parameter on the read side). No closed enum here (unlike `recipeVisibilitySchema`) — the training data's own locale list can grow without a schema change. */
|
||||||
|
export const auditBatchQuerySchema = workerBatchQuerySchema.extend({
|
||||||
|
locale: z.string().min(2).default("fr"),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link auditBatchQuerySchema}'s validated output. */
|
||||||
|
export type AuditBatchQueryInput = z.infer<typeof auditBatchQuerySchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One suggestion in the batch `POST /internal/tech-steps/training-suggestions`
|
||||||
|
* accepts — `techStepKey` (not an id) since the worker never has direct DB
|
||||||
|
* access to resolve one itself; the API resolves it, and rejects the whole
|
||||||
|
* batch with `TECH_STEP_NOT_FOUND` if any key is unknown (see
|
||||||
|
* `tech-step-worker.service.ts`). `sourceCorrectionId` is required when
|
||||||
|
* `sourceType` is `"correction"` (that's the whole point of that source —
|
||||||
|
* it exists *because of* one specific correction) and must be absent
|
||||||
|
* otherwise — enforced by the refinement below, not by two separate
|
||||||
|
* schemas, so the error message can point at exactly which field is wrong.
|
||||||
|
*/
|
||||||
|
const trainingSuggestionSchema = z
|
||||||
|
.object({
|
||||||
|
techStepKey: z.string().min(1),
|
||||||
|
locale: z.string().min(2),
|
||||||
|
suggestedSynonyms: z.array(z.string().trim().min(1)),
|
||||||
|
suggestedUtterances: z.array(z.string().trim().min(1)),
|
||||||
|
sourceType: z.enum(["correction", "llm_audit"]),
|
||||||
|
sourceCorrectionId: z.number().int().positive().nullable().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(input) =>
|
||||||
|
input.sourceType === "correction"
|
||||||
|
? input.sourceCorrectionId !== null && input.sourceCorrectionId !== undefined
|
||||||
|
: input.sourceCorrectionId === null || input.sourceCorrectionId === undefined,
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"sourceCorrectionId is required when sourceType is 'correction', and must be absent otherwise",
|
||||||
|
path: ["sourceCorrectionId"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Payload accepted by `POST /internal/tech-steps/training-suggestions` — a batch, not one suggestion per call, since the worker's audit/correction jobs naturally produce several at once per run and there's no reason to round-trip once per suggestion. */
|
||||||
|
export const submitTrainingSuggestionsSchema = z.object({
|
||||||
|
suggestions: z.array(trainingSuggestionSchema).min(1),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link submitTrainingSuggestionsSchema}'s validated output. */
|
||||||
|
export type SubmitTrainingSuggestionsInput = z.infer<typeof submitTrainingSuggestionsSchema>;
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
import type { AllergyView, DietView, IngredientView, TechStepView, UnitView } from "./reference.js";
|
import type {
|
||||||
|
AllergyView,
|
||||||
|
DietView,
|
||||||
|
IngredientView,
|
||||||
|
TechStepView,
|
||||||
|
UnitView,
|
||||||
|
UtensilView,
|
||||||
|
} from "./reference.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Who can *read* a recipe — mirrors `RecipeVisibility` in schema.prisma.
|
* Who can *read* a recipe — mirrors `RecipeVisibility` in schema.prisma.
|
||||||
|
|
@ -43,6 +50,17 @@ export interface RecipeIngredientView {
|
||||||
* and not yet recomputed (see `StepTechStep`'s schema doc comment) — a
|
* and not yet recomputed (see `StepTechStep`'s schema doc comment) — a
|
||||||
* caller with no context just shows the keyword highlight alone, same as
|
* caller with no context just shows the keyword highlight alone, same as
|
||||||
* before these existed.
|
* before these existed.
|
||||||
|
*
|
||||||
|
* `source` mirrors `StepTechStep.source` (schema.prisma) — `"auto"` is the
|
||||||
|
* classifier's own detection, `"manual"` is a viewer's correction applied
|
||||||
|
* immediately (`recipe-tech-step-correction.service.ts`'s
|
||||||
|
* `applyManualCorrection`). `StepDescription.tsx` renders the two with a
|
||||||
|
* different highlight color so a viewer can tell which is which.
|
||||||
|
*
|
||||||
|
* `ingredients`/`utensils` are the metadata found in this technique's own
|
||||||
|
* clause (see `tech-step-matcher.ts`'s `TechStepMatch` — same source data,
|
||||||
|
* just resolved to full reference views here instead of bare ids) — `[]`
|
||||||
|
* when nothing was mentioned alongside this technique.
|
||||||
*/
|
*/
|
||||||
export interface StepTechStepView {
|
export interface StepTechStepView {
|
||||||
techStep: TechStepView;
|
techStep: TechStepView;
|
||||||
|
|
@ -50,6 +68,39 @@ export interface StepTechStepView {
|
||||||
end: number;
|
end: number;
|
||||||
contextStart?: number;
|
contextStart?: number;
|
||||||
contextEnd?: number;
|
contextEnd?: number;
|
||||||
|
source: "auto" | "manual";
|
||||||
|
ingredients: StepTechStepIngredientView[];
|
||||||
|
utensils: StepTechStepUtensilView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An ingredient mentioned in the same clause as a detected technique (see
|
||||||
|
* {@link StepTechStepView.ingredients}) — `quantity`/`unit` are `null` when
|
||||||
|
* none was recognized immediately before the mention (e.g. "ajouter le
|
||||||
|
* sel"), same "best-effort, not always present" contract as
|
||||||
|
* `tech-step-matcher.ts`'s `IngredientMention`. `start`/`end` are the
|
||||||
|
* mention's own span in the step's `description`, same `[start, end)`
|
||||||
|
* convention as {@link StepTechStepView.start}.
|
||||||
|
*
|
||||||
|
* `source` mirrors {@link StepTechStepView.source} — `"auto"` is the
|
||||||
|
* classifier's own detection, `"manual"` is a viewer's own selection
|
||||||
|
* (`SubmitTechStepCorrectionInput.ingredients`, `TechStepCorrectionPopover.tsx`).
|
||||||
|
*/
|
||||||
|
export interface StepTechStepIngredientView {
|
||||||
|
ingredient: IngredientView;
|
||||||
|
quantity: number | null;
|
||||||
|
unit: UnitView | null;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
source: "auto" | "manual";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A utensil mentioned in the same clause as a detected technique (see {@link StepTechStepView.utensils}) — `source` mirrors {@link StepTechStepIngredientView.source}. */
|
||||||
|
export interface StepTechStepUtensilView {
|
||||||
|
utensil: UtensilView;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
source: "auto" | "manual";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -99,3 +150,42 @@ export interface RecipeView extends RecipeSummaryView {
|
||||||
ingredients: RecipeIngredientView[];
|
ingredients: RecipeIngredientView[];
|
||||||
steps: StepView[];
|
steps: StepView[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One user-submitted correction to a step's detected techniques, as
|
||||||
|
* returned by `GET /recipes/:id/steps/:stepId/corrections` — the read side
|
||||||
|
* of `POST` on the same route (`submitTechStepCorrectionSchema`,
|
||||||
|
* `packages/shared/src/schemas/recipe.ts`). `previousTechStep`/
|
||||||
|
* `correctedTechStep` are resolved to their reference data (same "resolve
|
||||||
|
* at read time" treatment as {@link StepTechStepView.techStep}) rather than
|
||||||
|
* bare ids — `null` carries the same "missing"/"none" meaning documented on
|
||||||
|
* `StepTechStepCorrection` in schema.prisma. Not shown to *every* viewer of
|
||||||
|
* a step by default in `apps/web` today (see `StepDescription.tsx`) —
|
||||||
|
* mainly useful for a user checking what they (or others) already
|
||||||
|
* submitted before adding another correction to the same span.
|
||||||
|
*/
|
||||||
|
export interface StepTechStepCorrectionView {
|
||||||
|
id: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
previousTechStep: TechStepView | null;
|
||||||
|
correctedTechStep: TechStepView | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response of `POST /recipes/:id/steps/:stepId/corrections` — the audit
|
||||||
|
* record just created, plus the step's fresh, immediately up-to-date
|
||||||
|
* `techSteps` sequence (`StepView.techSteps`'s own shape) after applying
|
||||||
|
* it. `apps/web`'s `StepDescription.tsx` replaces its local copy of the
|
||||||
|
* step's `techSteps` with this on a successful submit, so the new/relabeled
|
||||||
|
* `"manual"`-sourced highlight appears right away — the API is the single
|
||||||
|
* source of truth for exactly which entry changed and how (a relabel
|
||||||
|
* updates one row in place, an add inserts one, a remove deletes one; see
|
||||||
|
* `recipe-tech-step-correction.service.ts`'s `applyManualCorrection`), so
|
||||||
|
* the frontend never tries to replicate that logic client-side.
|
||||||
|
*/
|
||||||
|
export interface SubmitTechStepCorrectionResult {
|
||||||
|
correction: StepTechStepCorrectionView;
|
||||||
|
techSteps: StepTechStepView[];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,24 @@ export interface TechStepView {
|
||||||
key: string;
|
key: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cooking utensil, as returned by `GET /reference/utensils` — reference
|
||||||
|
* data (`Utensil`, seeded via `reference-seed-data.ts`'s `UTENSILS`), same
|
||||||
|
* bare `id`+`key` shape and static/non-administrable status as
|
||||||
|
* {@link TechStepView}. Detected in a step's free text the same way
|
||||||
|
* techniques are (see `StepTechStepUtensilView`), but via a static
|
||||||
|
* `PhraseMatcher` rather than a trained classifier — see
|
||||||
|
* `services/tech-step-intent-service`'s `utensil_vocabulary.py`.
|
||||||
|
*
|
||||||
|
* `key` is a stable English camelCase uid (e.g. `"pan"`), not a display
|
||||||
|
* label — resolved via `t(\`catalog.utensils.${key}\`)`, same as
|
||||||
|
* {@link TechStepView.key}.
|
||||||
|
*/
|
||||||
|
export interface UtensilView {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An implemented recipe source, as returned by `GET /reference/sources` —
|
* An implemented recipe source, as returned by `GET /reference/sources` —
|
||||||
* reference data (`Source`, kept in sync with the adapter registry by
|
* reference data (`Source`, kept in sync with the adapter registry by
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue