Compare commits
15 commits
main
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
| 79ed5afdba | |||
| f5f30923d0 | |||
| b8e599106f | |||
| 9f274f9461 | |||
| c73c62328d | |||
| 0582822a78 | |||
| 6574d8e4a8 | |||
| f0ffd9644e | |||
| 0c1743c614 | |||
| 4c3e00a08a | |||
| f741bbb4f1 | |||
| c190f3f21c | |||
| 76175bcdbf | |||
| 8d741ed13f | |||
| 9f68c144f3 |
167 changed files with 5861 additions and 18684 deletions
20
.env.example
20
.env.example
|
|
@ -21,23 +21,3 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# over plain HTTP a Secure cookie is silently never sent back by the
|
# 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
|
|
||||||
|
|
|
||||||
88
.github/workflows/ci.yml
vendored
88
.github/workflows/ci.yml
vendored
|
|
@ -12,32 +12,22 @@ on:
|
||||||
push:
|
push:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: "postgresql://ci:ci@localhost:5433/batchcooking_ci?schema=public"
|
DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public"
|
||||||
# Test-only secret, never used outside CI — real deployments must set their own.
|
# 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:
|
||||||
# Five independent jobs, no needs: between them — each starts in parallel
|
# Four independent jobs, no needs: between them — each starts in parallel
|
||||||
# and reports as its own check, instead of the previous single chained
|
# 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: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: https://github.com/pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -55,80 +45,34 @@ jobs:
|
||||||
POSTGRES_PASSWORD: ci
|
POSTGRES_PASSWORD: ci
|
||||||
POSTGRES_DB: batchcooking_ci
|
POSTGRES_DB: batchcooking_ci
|
||||||
ports:
|
ports:
|
||||||
- 5433:5432
|
- 5432: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: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: https://github.com/pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
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: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: https://github.com/pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -139,17 +83,17 @@ jobs:
|
||||||
e2e:
|
e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: https://github.com/pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Cache Cypress binary
|
- name: Cache Cypress binary
|
||||||
uses: https://github.com/actions/cache@v4
|
uses: 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') }}
|
||||||
|
|
|
||||||
16
.gitignore
vendored
16
.gitignore
vendored
|
|
@ -71,12 +71,11 @@ web_modules/
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.test.example
|
!.env.test.example
|
||||||
|
|
||||||
# Python virtualenvs/caches for services/tech-step-intent-service (this repo
|
# node-nlp's default auto-save file (apps/api/src/lib/recipe-matching/
|
||||||
# is otherwise all-Node — see that service's own .gitignore for the rest;
|
# tech-step-matcher.ts explicitly disables autoSave/autoLoad, but this is a
|
||||||
# duplicated here too since some tooling only honors the repo-root file).
|
# belt-and-suspenders guard against it ever reappearing — a stale trained
|
||||||
services/tech-step-intent-service/.venv/
|
# model on disk must never silently shadow TECH_STEP_TRAINING_DATA).
|
||||||
services/tech-step-intent-service/__pycache__/
|
model.nlp
|
||||||
services/tech-step-intent-service/.pytest_cache/
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
.cache
|
.cache
|
||||||
|
|
@ -158,8 +157,3 @@ 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,8 +44,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -102,17 +100,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -150,7 +137,7 @@ pnpm --filter web cy:run:component # tests de composant UI isolés (Cypress com
|
||||||
pnpm build # build de tous les workspaces
|
pnpm build # build de tous les workspaces
|
||||||
```
|
```
|
||||||
|
|
||||||
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute cinq jobs indépendants (`lint`, `test`, `intent-service-test`, `build`, `e2e` — ce dernier lance aussi `cy:run:component`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. `test` démarre `services/tech-step-intent-service` en arrière-plan (voir ce fichier) puisque la suite Mocha ne mocke jamais un service interne. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
|
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute quatre jobs indépendants (`lint`, `test`, `build`, `e2e` — ce dernier lance aussi `cy:run:component`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
|
||||||
|
|
||||||
### Base de test isolée de la base de dev (`apps/api`)
|
### Base de test isolée de la base de dev (`apps/api`)
|
||||||
|
|
||||||
|
|
@ -171,13 +158,6 @@ Un garde-fou (`assertRunningAgainstTestDatabase()`) refuse d'exécuter
|
||||||
`resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la
|
`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
|
||||||
|
|
@ -200,12 +180,10 @@ synchronisation de la table `sources` depuis le registre d'adaptateurs de code
|
||||||
puis `node dist/server.js`. Les trois étapes sont sûres/idempotentes à
|
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.
|
||||||
|
|
||||||
Le duo `postgres`/`app` de `docker-compose.yml` n'expose donc qu'un seul port
|
`docker-compose.yml` ne définit donc que deux services : `postgres` et `app` (un
|
||||||
applicatif, `APP_PORT` (défaut `3000`) — plus de `WEB_PORT`/`CORS_ORIGIN` à
|
seul port, `APP_PORT`, défaut `3000` — plus de `WEB_PORT`/`CORS_ORIGIN` à
|
||||||
coordonner entre deux origines, le frontend et l'API sont désormais servis
|
coordonner entre deux origines, le frontend et l'API sont désormais servis depuis
|
||||||
depuis la même origine. Les deux autres services du fichier
|
la même origine).
|
||||||
(`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
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,14 @@
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=3000
|
PORT=3000
|
||||||
# Match whatever you set in the root .env (POSTGRES_USER/PASSWORD/DB) —
|
# Match whatever you set in the root .env (POSTGRES_USER/PASSWORD/DB) —
|
||||||
# do not commit the real value.
|
# do not commit the real value.
|
||||||
DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking?schema=public"
|
DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking?schema=public"
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
# Optional — defaults shown (see src/config/env.ts)
|
# Optional — defaults shown (see src/config/env.ts)
|
||||||
# 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,17 +13,3 @@ 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,6 +2,5 @@
|
||||||
"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,6 +26,7 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "step_tech_step_correction" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"step_id" INTEGER NOT NULL,
|
|
||||||
"corrector_id" INTEGER NOT NULL,
|
|
||||||
"start" INTEGER NOT NULL,
|
|
||||||
"end" INTEGER NOT NULL,
|
|
||||||
"previous_tech_step_id" INTEGER,
|
|
||||||
"corrected_tech_step_id" INTEGER,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"consumed_at" TIMESTAMP(3),
|
|
||||||
|
|
||||||
CONSTRAINT "step_tech_step_correction_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "tech_step_training_suggestion" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"tech_step_id" INTEGER NOT NULL,
|
|
||||||
"locale" TEXT NOT NULL,
|
|
||||||
"suggested_synonyms" TEXT[],
|
|
||||||
"suggested_utterances" TEXT[],
|
|
||||||
"source_type" TEXT NOT NULL,
|
|
||||||
"source_correction_id" INTEGER,
|
|
||||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "tech_step_training_suggestion_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_step_id_fkey" FOREIGN KEY ("step_id") REFERENCES "step"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_corrector_id_fkey" FOREIGN KEY ("corrector_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_previous_tech_step_id_fkey" FOREIGN KEY ("previous_tech_step_id") REFERENCES "tech_step"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_corrected_tech_step_id_fkey" FOREIGN KEY ("corrected_tech_step_id") REFERENCES "tech_step"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "tech_step_training_suggestion" ADD CONSTRAINT "tech_step_training_suggestion_tech_step_id_fkey" FOREIGN KEY ("tech_step_id") REFERENCES "tech_step"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "tech_step_training_suggestion" ADD CONSTRAINT "tech_step_training_suggestion_source_correction_id_fkey" FOREIGN KEY ("source_correction_id") REFERENCES "step_tech_step_correction"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "step_tech_step" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'auto';
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "utensil" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"key" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "utensil_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "utensil_key_key" ON "utensil"("key");
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "step_tech_step_ingredient" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"step_id" INTEGER NOT NULL,
|
|
||||||
"tech_step_order" INTEGER NOT NULL,
|
|
||||||
"ingredient_id" INTEGER NOT NULL,
|
|
||||||
"quantity" DECIMAL(10,2),
|
|
||||||
"unit_id" INTEGER,
|
|
||||||
"start" INTEGER NOT NULL,
|
|
||||||
"end" INTEGER NOT NULL,
|
|
||||||
"source" TEXT NOT NULL DEFAULT 'auto',
|
|
||||||
|
|
||||||
CONSTRAINT "step_tech_step_ingredient_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "step_tech_step_utensil" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"step_id" INTEGER NOT NULL,
|
|
||||||
"tech_step_order" INTEGER NOT NULL,
|
|
||||||
"utensil_id" INTEGER NOT NULL,
|
|
||||||
"start" INTEGER NOT NULL,
|
|
||||||
"end" INTEGER NOT NULL,
|
|
||||||
"source" TEXT NOT NULL DEFAULT 'auto',
|
|
||||||
|
|
||||||
CONSTRAINT "step_tech_step_utensil_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_step_id_tech_step_order_fkey" FOREIGN KEY ("step_id", "tech_step_order") REFERENCES "step_tech_step"("step_id", "order") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_unit_id_fkey" FOREIGN KEY ("unit_id") REFERENCES "unit"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_utensil" ADD CONSTRAINT "step_tech_step_utensil_step_id_tech_step_order_fkey" FOREIGN KEY ("step_id", "tech_step_order") REFERENCES "step_tech_step"("step_id", "order") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_utensil" ADD CONSTRAINT "step_tech_step_utensil_utensil_id_fkey" FOREIGN KEY ("utensil_id") REFERENCES "utensil"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
@ -121,10 +121,6 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
@ -515,15 +511,12 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
@ -599,13 +592,7 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
@ -636,50 +623,22 @@ 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 spaCy-based model (`services/tech-step-intent-service`) trained from
|
/// a node-nlp model trained from in-code data
|
||||||
/// in-code data (`tech-step-training-data.ts`) once regexes turned out
|
/// (`tech-step-training-data.ts`) once regexes turned out unable to
|
||||||
/// unable to generalize past their own literal vocabulary. Nothing
|
/// generalize past their own literal vocabulary. Nothing queries/edits
|
||||||
/// queries/edits that matching data at runtime anymore (it only ever feeds
|
/// that matching data at runtime anymore (it only ever feeds the
|
||||||
/// that service's one-time training pass), so it no longer needs a table
|
/// classifier's one-time training pass), so it no longer needs a table of
|
||||||
/// of its own — this row now only exists to be a stable id/key other
|
/// its own — this row now only exists to be a stable id/key other tables
|
||||||
/// tables (`StepTechStep`) reference.
|
/// (`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
|
||||||
|
|
@ -691,11 +650,8 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
@ -726,174 +682,18 @@ 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,13 +7,11 @@ 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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -40,18 +38,11 @@ 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,36 +60,6 @@ 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,12 +62,11 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }>
|
||||||
//
|
//
|
||||||
// Just a flat list of stable ids here — the actual matching data (per-
|
// 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 `services/tech-step-intent-service/intent_service/training_data.py`'s
|
// in `lib/recipe-matching/tech-step-training-data.ts`'s
|
||||||
// `TECH_STEP_TRAINING_DATA`, not here: it's owned and trained entirely by
|
// `TECH_STEP_TRAINING_DATA`, not here: unlike this list, it's read by
|
||||||
// that separate Python service (see its own README), not read by this
|
// `TechStepClassifierService`'s training pass, not the seed script, so it
|
||||||
// seed script at all, so it doesn't belong alongside the rest of this
|
// doesn't belong alongside the rest of this file's DB-seeded reference
|
||||||
// file's DB-seeded reference data. Every entry here must have a matching
|
// data. Every entry here must have a matching entry there.
|
||||||
// entry there.
|
|
||||||
export const TECH_STEPS: string[] = [
|
export const TECH_STEPS: string[] = [
|
||||||
"cook",
|
"cook",
|
||||||
"fry",
|
"fry",
|
||||||
|
|
@ -95,100 +94,6 @@ 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
|
||||||
|
|
@ -1294,12 +1199,6 @@ 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,26 +1,23 @@
|
||||||
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 a recipe source (`ParsedRecipeIngredient`) against our own
|
* lifts from an English-language source (`ParsedRecipeIngredient`) against
|
||||||
* `Ingredient`/`Unit` reference catalogs — the ingredient-side counterpart
|
* our own `Ingredient`/`Unit` reference catalogs — the ingredient-side
|
||||||
* to `tech-step-matcher.ts`'s technique detection, built for the same
|
* counterpart to `tech-step-matcher.ts`'s technique detection, built for
|
||||||
* reason: a source's raw text has no idea our catalogs even exist.
|
* the same reason: an English source's raw text has no idea our catalogs
|
||||||
|
* even exist.
|
||||||
*
|
*
|
||||||
* Unlike tech steps (regex mappings hand-authored per technique),
|
* Unlike tech steps (regex mappings hand-authored per technique),
|
||||||
* ingredient/unit labels are plain hand-written text, one table per
|
* ingredient/unit labels are plain hand-written English text
|
||||||
* supported `locale` (`INGREDIENT_LABELS_EN`/`INGREDIENT_LABELS_FR`/
|
* (`INGREDIENT_LABELS_EN`/`UNIT_LABELS_EN`, `packages/shared`) — matching
|
||||||
* `UNIT_LABELS_EN`/`UNIT_LABELS_FR`, `packages/shared`) — matching them
|
* them against arbitrary free text (extra adjectives, plurals, "large diced
|
||||||
* 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
|
||||||
|
|
@ -39,17 +36,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 matching label in whichever locale it was loaded for. */
|
/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its English matching label. */
|
||||||
export interface IngredientMatchEntry {
|
export interface IngredientMatchEntry {
|
||||||
ingredientId: number;
|
ingredientId: number;
|
||||||
/** Matching label — English (`INGREDIENT_LABELS_EN`) or French (`INGREDIENT_LABELS_FR`) depending on which locale {@link loadIngredientCatalog} was called with, e.g. `"Chicken breast"`/`"Blanc de poulet"` — matched against free text, never displayed. */
|
/** English label from `INGREDIENT_LABELS_EN`, e.g. `"Chicken breast"` — matched against free text, never displayed. */
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One `Unit` row trimmed to what {@link matchUnit} needs, alongside its accepted spellings in whichever locale it was loaded for. */
|
/** One `Unit` row trimmed to what {@link matchUnit} needs, alongside its accepted English spellings. */
|
||||||
export interface UnitMatchEntry {
|
export interface UnitMatchEntry {
|
||||||
unitId: number;
|
unitId: number;
|
||||||
/** Accepted spellings — English (`UNIT_LABELS_EN`) or French (`UNIT_LABELS_FR`), e.g. `["tbsp", "tbs", "tablespoon", "tablespoons"]` or `["cuillère à soupe", "cas", ...]`. Unlike English, a French entry can be genuinely multi-word — see `matchUnit`'s own doc comment. */
|
/** Accepted spellings from `UNIT_LABELS_EN`, e.g. `["tbsp", "tbs", "tablespoon", "tablespoons"]`. */
|
||||||
synonyms: string[];
|
synonyms: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,45 +58,19 @@ 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 stemWordEn(word: string): string {
|
function stemWord(word: string): string {
|
||||||
if (word.endsWith("ies") && word.length > 4) return `${word.slice(0, -3)}y`;
|
if (word.endsWith("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. */
|
||||||
* Naive French stemmer — French regular plurals are overwhelmingly just
|
function tokenize(text: string): string[] {
|
||||||
* "+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((word) => stemWord(word, locale));
|
.map(stemWord);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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. */
|
||||||
|
|
@ -111,193 +82,22 @@ 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`, **in the same order**, the one
|
* all appear as a contiguous run in `name`, the one with the most words
|
||||||
* with the most words wins (most specific — "chicken breast" over bare
|
* wins (most specific — "chicken breast" over bare "chicken"); ties break
|
||||||
* "chicken"); ties break on the lowest `ingredientId`, for a deterministic
|
* on the lowest `ingredientId`, for a deterministic result independent of
|
||||||
* result independent of catalog order. `locale` must match whatever
|
* catalog order.
|
||||||
* `catalog`'s labels were loaded in (see {@link loadIngredientCatalog}) —
|
|
||||||
* defaults to `"en"`.
|
|
||||||
*/
|
*/
|
||||||
export function matchIngredientName(
|
export function matchIngredientName(name: string, catalog: IngredientMatchEntry[]): number | null {
|
||||||
name: string,
|
const nameTokens = tokenize(name);
|
||||||
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, locale);
|
const labelTokens = tokenize(entry.label);
|
||||||
if (!containsSubsequence(nameTokens, labelTokens)) continue;
|
if (!containsSubsequence(nameTokens, labelTokens)) continue;
|
||||||
if (
|
if (
|
||||||
best === null ||
|
best === null ||
|
||||||
|
|
@ -314,44 +114,24 @@ export function matchIngredientName(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves free-text `unitText` (e.g. `"tbsp"`, `"Cups"`, `"cuillères à
|
* Resolves free-text `unitText` (e.g. `"tbsp"`, `"Cups"`) to the matching
|
||||||
* soupe de farine"`) to the best-matching `Unit` in `catalog`, or `null` if
|
* `Unit` in `catalog`, or `null` if nothing matches. A unit is a single
|
||||||
* nothing matches. Same ordered-contiguous-run search as
|
* word by convention (see `UNIT_LABELS_EN`), so this is a whole-token
|
||||||
* {@link matchIngredientName}, longest match wins — **not** the
|
* equality check (after stemming/normalizing), not the substring search
|
||||||
* single-first-word equality check this function used before French
|
* `matchIngredientName` does — `"cup"` shouldn't match inside an unrelated
|
||||||
* support existed: every English unit synonym happens to be one word, so
|
* longer word.
|
||||||
* 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(
|
export function matchUnit(unitText: string, catalog: UnitMatchEntry[]): number | null {
|
||||||
unitText: string,
|
const tokens = tokenize(unitText);
|
||||||
catalog: UnitMatchEntry[],
|
if (tokens.length === 0) return null;
|
||||||
locale = "en",
|
const firstToken = tokens[0];
|
||||||
): 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) {
|
||||||
for (const synonym of entry.synonyms) {
|
if (entry.synonyms.some((synonym) => stemWord(normalizeText(synonym)) === firstToken)) {
|
||||||
const synonymTokens = tokenize(synonym, locale);
|
return entry.unitId;
|
||||||
if (!containsSubsequence(textTokens, synonymTokens)) continue;
|
|
||||||
if (
|
|
||||||
best === null ||
|
|
||||||
synonymTokens.length > best.tokenCount ||
|
|
||||||
(synonymTokens.length === best.tokenCount && entry.unitId < best.unitId)
|
|
||||||
) {
|
|
||||||
best = { unitId: entry.unitId, tokenCount: synonymTokens.length };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return best?.unitId ?? null;
|
return 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. */
|
||||||
|
|
@ -363,10 +143,7 @@ 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. The
|
// good enough for a best-effort quantity, not meant to model ranges.
|
||||||
// `[.,]` 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+)?)/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -399,42 +176,18 @@ 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. */
|
||||||
* Per-locale matching label tables {@link loadIngredientCatalog}/
|
export async function loadIngredientCatalog(): Promise<IngredientMatchEntry[]> {
|
||||||
* {@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 = labels[ingredient.key];
|
const label = INGREDIENT_LABELS_EN[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 synonyms[ingredient.key] ?? []) {
|
for (const synonym of INGREDIENT_LABEL_SYNONYMS_EN[ingredient.key] ?? []) {
|
||||||
catalog.push({ ingredientId: ingredient.id, label: synonym });
|
catalog.push({ ingredientId: ingredient.id, label: synonym });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -447,16 +200,15 @@ export async function loadIngredientCatalog(locale = "en"): Promise<IngredientMa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loads the full `Unit` catalog as {@link UnitMatchEntry}s for `locale` (default `"en"`) — one entry per key with authored synonyms in that locale (see {@link UNIT_LABELS_BY_LOCALE}); a unit with none yet is silently skipped. Meant to be fetched once per request, same reasoning as {@link loadIngredientCatalog}. */
|
/** Loads the full `Unit` catalog as {@link UnitMatchEntry}s — one entry per key with authored English synonyms (see `UNIT_LABELS_EN`); a unit with none yet is silently skipped. Meant to be fetched once per request, same reasoning as {@link loadIngredientCatalog}. */
|
||||||
export async function loadUnitCatalog(locale = "en"): Promise<UnitMatchEntry[]> {
|
export async function loadUnitCatalog(): 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 = labels[unit.key];
|
const synonyms = UNIT_LABELS_EN[unit.key];
|
||||||
if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms });
|
if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms });
|
||||||
}
|
}
|
||||||
return catalog;
|
return catalog;
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { env } from "../../config/env.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thin fetch wrapper around `services/tech-step-intent-service`'s HTTP
|
|
||||||
* contract (`POST /v1/process`) — the microservice
|
|
||||||
* {@link TechStepClassifierService} (`tech-step-matcher.ts`) delegates NER +
|
|
||||||
* intent classification to, in place of the `node-nlp` `NlpManager` it used
|
|
||||||
* to own directly. See that service's own README for the full contract and
|
|
||||||
* why it never touches Postgres itself — it also owns its own training
|
|
||||||
* corpus now (`training_data.py`), trained once at its own startup, so
|
|
||||||
* `apps/api` never pushes anything to it; `process()` below is this
|
|
||||||
* client's only method.
|
|
||||||
*
|
|
||||||
* Authenticated with `INTENT_SERVICE_SECRET` — the inverse direction of
|
|
||||||
* `requireInternalWorker`'s `INTERNAL_WORKER_SECRET` (this time `apps/api`
|
|
||||||
* is the caller, not the callee), but the same "one flat shared secret"
|
|
||||||
* shape.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** One candidate mention one of the service's two `PhraseMatcher`s found — offsets `[start, end)`, same convention as `String.prototype.slice`. Mirrors `EntityPayload` (Python `schemas.py`). `kind` distinguishes a technique mention (`self._matcher`, the corpus-trained one) from a utensil mention (`self._utensil_matcher`, static — see `utensil_vocabulary.py`) — `tech-step-matcher.ts` resolves each against a different catalog (`TechStep`/`Utensil`). */
|
|
||||||
export interface IntentServiceEntity {
|
|
||||||
uid: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
kind: "technique" | "utensil";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The full result of a `POST /v1/process` call — mirrors `ProcessResponse` (Python `schemas.py`). `intent` is `null` only when `locale` isn't one this service trains for, or `text` is blank; otherwise always a real `uid` (the Python service's `textcat` has no "None" sentinel, unlike node-nlp — see that service's README). */
|
|
||||||
export interface IntentServiceProcessResult {
|
|
||||||
entities: IntentServiceEntity[];
|
|
||||||
intent: string | null;
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client for `services/tech-step-intent-service` — a real class (not a
|
|
||||||
* plain object of functions) per this repo's service-style-logic
|
|
||||||
* convention, even though it holds no state of its own: it's used as the
|
|
||||||
* one shared {@link intentServiceClient} singleton below, same reasoning as
|
|
||||||
* `TechStepClassifierService` itself.
|
|
||||||
*/
|
|
||||||
export class IntentServiceClient {
|
|
||||||
/**
|
|
||||||
* Performs a JSON request against the intent service and returns the
|
|
||||||
* parsed body.
|
|
||||||
*
|
|
||||||
* @throws {Error} if the response status is not in the 2xx range, or the
|
|
||||||
* request itself fails (network error, service down) — left as a plain
|
|
||||||
* `Error` rather than a typed `HttpError`: this is an internal
|
|
||||||
* service-to-service call, not a request `apps/api`'s own HTTP layer
|
|
||||||
* needs to map to a client-facing status code (see
|
|
||||||
* `TechStepClassifierService.warmUp`'s retry in `server.ts` for how a
|
|
||||||
* failure here is actually handled).
|
|
||||||
*/
|
|
||||||
private async _request<TResponseBody>(
|
|
||||||
path: string,
|
|
||||||
init: RequestInit = {},
|
|
||||||
): Promise<TResponseBody> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${env.INTENT_SERVICE_BASE_URL}${path}`, {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Intent-Service-Secret": env.INTENT_SERVICE_SECRET,
|
|
||||||
...init.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.text().catch(() => "");
|
|
||||||
throw new Error(`${init.method ?? "GET"} ${path} failed: ${response.status} ${body}`);
|
|
||||||
}
|
|
||||||
return (await response.json()) as TResponseBody;
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — every caller (`TechStepClassifierService`) already
|
|
||||||
// wraps its own `await`s per the repo's try/catch convention; this is
|
|
||||||
// just where the `await` itself has to sit inside one.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Equivalent to the old `NlpManager.process(locale, text)` — returns every
|
|
||||||
* candidate technique mention (NER) plus the intent classifier's verdict
|
|
||||||
* for `text` as a whole, whether `text` is a full step description or a
|
|
||||||
* single clause `TechStepClassifierService` already cut out of one (this
|
|
||||||
* service doesn't know or care which, exactly like `NlpManager` before
|
|
||||||
* it).
|
|
||||||
*/
|
|
||||||
public async process(locale: string, text: string): Promise<IntentServiceProcessResult> {
|
|
||||||
try {
|
|
||||||
return await this._request("/v1/process", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ locale, text }),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Single shared instance — stateless, no reason for more than one (same reasoning as `techStepClassifier`/`prisma`). */
|
|
||||||
export const intentServiceClient = new IntentServiceClient();
|
|
||||||
|
|
@ -100,19 +100,12 @@ export async function translateRecipeSteps(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Locale-specific label {@link matchUnit} is fed when a quantity was found
|
* English label {@link matchUnit} is fed when a quantity was found but no
|
||||||
* but no unit word was — see the `unitId` fallback below. Each is the
|
* unit word was — see the `unitId` fallback below. `"piece"` (`UNIT_LABELS_EN`,
|
||||||
* catalog's generic "counted, no further unit" entry (`Unit.key` `"piece"`)
|
* `packages/shared`) is the catalog's generic "counted, no further unit"
|
||||||
* in that locale's own label table (`UNIT_LABELS_EN`/`UNIT_LABELS_FR`,
|
* entry (French "unité").
|
||||||
* `packages/shared`). A locale with neither entry (anything but
|
|
||||||
* `"en"`/`"fr"`) falls back to the English spelling — harmless, since
|
|
||||||
* `unitCatalog` itself is already empty for an unsupported locale (see
|
|
||||||
* `loadUnitCatalog`), so this fallback lookup finds nothing either way.
|
|
||||||
*/
|
*/
|
||||||
const FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE: Record<string, string> = {
|
const FALLBACK_COUNT_UNIT_LABEL = "piece";
|
||||||
en: "piece",
|
|
||||||
fr: "unité",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
|
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
|
||||||
|
|
@ -134,28 +127,20 @@ const FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE: Record<string, string> = {
|
||||||
* button disabled with no indication why on almost any recipe with a
|
* 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, locale);
|
const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog);
|
||||||
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, locale) ??
|
matchUnit(unitText, unitCatalog) ??
|
||||||
(quantity !== null ? matchUnit(fallbackCountUnitLabel, unitCatalog, locale) : null);
|
(quantity !== null ? matchUnit(FALLBACK_COUNT_UNIT_LABEL, unitCatalog) : null);
|
||||||
return { ...ingredient, quantity, ingredientId, unitId };
|
return { ...ingredient, quantity, ingredientId, unitId };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -288,19 +273,13 @@ export function mergeDuplicateIngredients(
|
||||||
* locale that doesn't match the actual text's language doesn't degrade
|
* 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 has data for `"en"` and `"fr"` today
|
* Ingredient/unit matching only has English data today
|
||||||
* (`INGREDIENT_LABELS_EN`/`_FR`, `UNIT_LABELS_EN`/`_FR`, `packages/shared`)
|
* (`INGREDIENT_LABELS_EN`/`UNIT_LABELS_EN`, `packages/shared`) — for any
|
||||||
* — `loadIngredientCatalog(locale)`/`loadUnitCatalog(locale)` are always
|
* `locale` other than `"en"` this skips `loadIngredientCatalog`/
|
||||||
* called, never specially skipped for a particular locale: a locale with no
|
* `loadUnitCatalog` entirely and leaves every ingredient's `ingredientId`/
|
||||||
* label table of its own (anything but `"en"`/`"fr"`) just gets back empty
|
* `unitId` at the neutral `null` `translateRecipeSteps` already stubs in,
|
||||||
* catalogs from those two loaders, and `translateRecipeIngredients` over an
|
* the same "no matching-language data" degradation tech-step matching
|
||||||
* empty catalog naturally leaves every ingredient's `ingredientId`/`unitId`
|
* already has for a locale with no mappings.
|
||||||
* 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,
|
||||||
|
|
@ -309,18 +288,15 @@ 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(locale),
|
loadIngredientCatalog(),
|
||||||
loadUnitCatalog(locale),
|
loadUnitCatalog(),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
...translated,
|
...translated,
|
||||||
ingredients: translateRecipeIngredients(
|
ingredients: translateRecipeIngredients(recipe.ingredients, ingredientCatalog, unitCatalog),
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
/**
|
|
||||||
* Hand-labeled evaluation set for {@link techStepClassifier} — what
|
|
||||||
* `tech-step-eval.test.ts` runs the real classifier against to compute
|
|
||||||
* precision/recall/F1 (`tech-step-evaluator.ts`), the objective gate any
|
|
||||||
* future change to `services/tech-step-intent-service`'s `training_data.py`
|
|
||||||
* must clear (see that module's own doc comment).
|
|
||||||
*
|
|
||||||
* Deliberately *not* reusing `TECH_STEP_TRAINING_DATA`'s own `utterances`
|
|
||||||
* verbatim — scoring the classifier against the exact sentences it was
|
|
||||||
* trained on would measure memorization, not generalization. Every
|
|
||||||
* description below is original phrasing; where a case still needs to name
|
|
||||||
* a technique's own verb to be labeled with confidence (most of them, see
|
|
||||||
* this file's own limits below), it's at least a different sentence shape
|
|
||||||
* than anything in the training corpus.
|
|
||||||
*
|
|
||||||
* `expectedKeys` is a multiset in reading order (see
|
|
||||||
* `TechStepEvalOutcome`'s doc comment in `tech-step-evaluator.ts` for why
|
|
||||||
* order isn't scored but repetition is) of `TechStep.key`s — resolved to
|
|
||||||
* real DB ids and back by `tech-step-eval-runner.ts`, this file only ever
|
|
||||||
* deals in stable keys so it doesn't need DB access to author or read.
|
|
||||||
*
|
|
||||||
* Known limit of this dataset, confirmed against a real run (see
|
|
||||||
* `MIN_OVERALL_F1`'s own doc comment, `tech-step-eval-runner.ts`): most
|
|
||||||
* cases anchor on a technique's own registered synonym, but `_classifyClause`
|
|
||||||
* only falls back to that anchor when the intent classifier's own score is
|
|
||||||
* *below* `CONFIDENCE_THRESHOLD` — a confidently *wrong* whole-clause
|
|
||||||
* classification (e.g. "Blanchissez les haricots verts..." scoring
|
|
||||||
* confidently as `peel` despite the correct `blanch` anchor) overrides the
|
|
||||||
* anchor just as readily as a confidently *right* one does, so this
|
|
||||||
* dataset genuinely does measure real classifier failures, not just a
|
|
||||||
* synthetic floor. A handful of such real mismatches are expected and
|
|
||||||
* intentionally left uncorrected here (see `MIN_OVERALL_F1`'s doc comment)
|
|
||||||
* — fixing the classifier's actual behavior on them is corpus work for a
|
|
||||||
* future change, not something to hide by loosening this dataset's own
|
|
||||||
* expectations to match whatever it currently outputs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface TechStepEvalCase {
|
|
||||||
description: string;
|
|
||||||
locale: "fr" | "en";
|
|
||||||
expectedKeys: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TECH_STEP_EVAL_DATASET: TechStepEvalCase[] = [
|
|
||||||
// --- One straightforward case per technique (fr), covering all 26 ---
|
|
||||||
{
|
|
||||||
description: "Faites cuire les pâtes al dente dans une grande casserole d'eau bien salée.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["cook"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites bouillir l'eau dans une grande casserole avant d'y plonger les pâtes.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["boil"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Plongez les beignets dans l'huile très chaude pour les faire frire.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["fry"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites fondre le chocolat noir au bain-marie en remuant.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["melt"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Déglacez la casserole avec un trait de vinaigre balsamique.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["deglaze"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Laissez frémir la sauce tomate vingt minutes à couvert.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["simmer"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites rôtir la volaille entière sur la broche du four.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["roast"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites griller les brochettes de poulet quelques minutes de chaque côté.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["grill"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites sauter les champignons à feu vif dans une poêle très chaude.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["panFry"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Blanchissez les haricots verts trois minutes avant de les refroidir.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["blanch"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Laissez mariner les brochettes de poulet deux heures au frais.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["marinate"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Hachez grossièrement le persil frais avant de le parsemer.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["chop"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Épluchez les carottes avant de les couper en rondelles.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["peel"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Émincez finement l'échalote pour la vinaigrette.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["mince"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Mélangez la farine, le sucre et les œufs dans un grand saladier.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["mix"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Fouettez énergiquement la crème jusqu'à ce qu'elle épaississe.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["whisk"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Incorporez délicatement la farine tamisée à la préparation.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["foldIn"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Réservez la pâte au réfrigérateur pendant que vous préparez la garniture.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["setAside"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Assaisonnez le poisson avec du sel, du poivre et un filet de citron.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["season"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Égouttez soigneusement le riz dans une passoire fine.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["drain"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Faites dorer les morceaux de veau sur toutes leurs faces avant de mouiller avec le bouillon.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["brown"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Laissez reposer la viande dix minutes avant de la trancher.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["rest"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Préchauffez le four à 200 degrés avant d'y glisser le gratin.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["preheat"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Enfournez la tarte pendant trente-cinq minutes jusqu'à ce qu'elle soit dorée.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["bake"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Dressez harmonieusement les légumes autour de la pièce de viande.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["plate"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Nappez le fond du moule d'une fine couche de caramel.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["coat"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- English coverage (same technique verbs, distinct sentences) ---
|
|
||||||
{
|
|
||||||
description: "Simmer the stock gently for forty minutes, skimming occasionally.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: ["simmer"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Peel the potatoes and rinse them under cold water.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: ["peel"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Whisk the eggs with a pinch of salt until frothy.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: ["whisk"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Season the soup generously with black pepper before serving.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: ["season"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Make sure the chicken is cooked through before serving.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: ["cook"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Multi-technique sentences, in reading order ---
|
|
||||||
{
|
|
||||||
description: "Préchauffez le four, puis faites rôtir le poulet pendant une heure.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["preheat", "roast"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Faites revenir les oignons, puis déglacez la poêle avec du vin blanc.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["brown", "deglaze"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Faites cuire les légumes à la vapeur, puis assaisonnez-les avec des herbes fraîches.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["cook", "season"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Émincez l'oignon, faites-le suer, puis mouillez avec le bouillon et laissez mijoter.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: ["mince", "simmer"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- No technique mentioned at all ---
|
|
||||||
{
|
|
||||||
description: "Répartissez les convives autour de la table avant de commencer le repas.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Rangez les couverts propres dans le tiroir de la cuisine.",
|
|
||||||
locale: "fr",
|
|
||||||
expectedKeys: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: "Take the dishes and glasses out of the cupboard.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: [],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Documented false-positive traps, re-verified with fresh wording ---
|
|
||||||
// `brown`'s EN synonyms are verb forms only ("browned"/"browning"), not
|
|
||||||
// bare "brown" — precisely so this doesn't false-positive (see that
|
|
||||||
// entry's own comment in training_data.py).
|
|
||||||
{
|
|
||||||
description: "This recipe calls for two tablespoons of brown sugar.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: [],
|
|
||||||
},
|
|
||||||
// `rest`'s EN synonyms are anchored phrases ("let it rest"/"resting
|
|
||||||
// for"...), not bare "rest" — so a sentence using the word in its
|
|
||||||
// "remainder" sense must not anchor `rest` at all.
|
|
||||||
{
|
|
||||||
description: "There is no time to rest before the guests arrive.",
|
|
||||||
locale: "en",
|
|
||||||
expectedKeys: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import { TECH_STEP_EVAL_DATASET } from "./tech-step-eval-dataset.js";
|
|
||||||
import {
|
|
||||||
computeTechStepMetrics,
|
|
||||||
type TechStepEvalOutcome,
|
|
||||||
type TechStepEvalResult,
|
|
||||||
} from "./tech-step-evaluator.js";
|
|
||||||
import { techStepClassifier } from "./tech-step-matcher.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression floor (not a target) both `runTechStepEvalSuite`'s consumers
|
|
||||||
* gate on — exported from here (not defined separately in each consumer)
|
|
||||||
* so the CI regression gate and `scripts/retrain-tech-steps.ts`'s
|
|
||||||
* pre-backfill gate can never silently drift to different thresholds.
|
|
||||||
*
|
|
||||||
* Calibrated against a real run: the classifier trained on the corpus as
|
|
||||||
* of this constant's introduction scored **0.815** aggregate F1
|
|
||||||
* (33 TP / 9 FP / 6 FN) against `TECH_STEP_EVAL_DATASET` — `0.8` leaves a
|
|
||||||
* small margin below that for run-to-run noise while still catching a
|
|
||||||
* real regression (not a floor picked blind before ever running this
|
|
||||||
* suite — see this feature's plan document for that earlier state). The
|
|
||||||
* mismatches this run surfaced (e.g. "Blanchissez les haricots verts..."
|
|
||||||
* misclassified as `peel`, a handful of anchor-less sentences expected to
|
|
||||||
* match nothing instead scoring confidently as some technique) are real,
|
|
||||||
* known classifier weaknesses — evidence this harness is doing its job,
|
|
||||||
* not something to quietly paper over by loosening the dataset's own
|
|
||||||
* expectations. Improving them is corpus work for a future change, gated
|
|
||||||
* by this same suite.
|
|
||||||
*/
|
|
||||||
export const MIN_OVERALL_F1 = 0.8;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs {@link TECH_STEP_EVAL_DATASET} against the real, currently-trained
|
|
||||||
* `techStepClassifier` and returns the aggregate/per-technique metrics
|
|
||||||
* (`computeTechStepMetrics`, `tech-step-evaluator.ts`) — the one place this
|
|
||||||
* DB-touching "resolve ids to keys, then score" logic lives, shared by
|
|
||||||
* `test/recipe-matching/tech-step-eval.test.ts` (this feature's CI
|
|
||||||
* regression gate) and `scripts/retrain-tech-steps.ts` (the same gate, run
|
|
||||||
* by a maintainer before applying a corpus change). Kept out of
|
|
||||||
* `tech-step-evaluator.ts` itself, which is deliberately pure/DB-free (see
|
|
||||||
* that module's own doc comment) so its scoring logic stays unit-testable
|
|
||||||
* without a database.
|
|
||||||
*/
|
|
||||||
export async function runTechStepEvalSuite(): Promise<TechStepEvalResult> {
|
|
||||||
const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
|
|
||||||
const keyById = new Map(techSteps.map((techStep) => [techStep.id, techStep.key]));
|
|
||||||
|
|
||||||
const outcomes: TechStepEvalOutcome[] = [];
|
|
||||||
for (const evalCase of TECH_STEP_EVAL_DATASET) {
|
|
||||||
const techStepIds = await techStepClassifier.matchTechSteps(
|
|
||||||
evalCase.description,
|
|
||||||
evalCase.locale,
|
|
||||||
);
|
|
||||||
const actualKeys = techStepIds.map((id) => {
|
|
||||||
const key = keyById.get(id);
|
|
||||||
// A `techStepId` the classifier resolved that isn't in the seeded
|
|
||||||
// catalog would be a bug in the classifier or the seed data, not
|
|
||||||
// this dataset — fail loudly rather than silently dropping it (see
|
|
||||||
// `_train`'s own comment in `tech-step-matcher.ts` on the
|
|
||||||
// equivalent, deliberately silent `undefined` case it has to
|
|
||||||
// tolerate for a different reason).
|
|
||||||
if (key === undefined) {
|
|
||||||
throw new Error(`Unknown TechStep id ${id} returned for "${evalCase.description}"`);
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
});
|
|
||||||
outcomes.push({ expectedKeys: evalCase.expectedKeys, actualKeys });
|
|
||||||
}
|
|
||||||
|
|
||||||
return computeTechStepMetrics(outcomes);
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
/**
|
|
||||||
* Precision/recall/F1 for {@link techStepClassifier}'s output against a
|
|
||||||
* hand-labeled evaluation set (`tech-step-eval-dataset.ts`) — the objective
|
|
||||||
* counterpart to the "inspected by eye" verdict every corpus change used to
|
|
||||||
* get before this module existed. Every future edit to
|
|
||||||
* `services/tech-step-intent-service`'s `training_data.py` (including the
|
|
||||||
* LLM-assisted suggestions the worker in `services/tech-step-llm-worker`
|
|
||||||
* proposes) is expected to run
|
|
||||||
* through `tech-step-eval.test.ts`'s regression gate, which calls
|
|
||||||
* {@link computeTechStepMetrics} — a corpus change that raises recall on one
|
|
||||||
* technique but silently tanks another's precision should fail loudly here,
|
|
||||||
* not get merged on the strength of a few manually-checked examples.
|
|
||||||
*
|
|
||||||
* Pure (no DB/model access) so it's unit-testable on its own — same
|
|
||||||
* convention as `tech-step-matcher.ts`'s own pure helpers (`normalizeText`,
|
|
||||||
* `splitIntoClauses`): this module only ever receives already-resolved
|
|
||||||
* `TechStep.key` strings, never DB ids or a live classifier instance, so it
|
|
||||||
* has nothing to mock to test.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** True/false-positive/negative counts for one technique (or the aggregate across all of them), plus the precision/recall/F1 derived from them. */
|
|
||||||
export interface TechStepMetrics {
|
|
||||||
truePositives: number;
|
|
||||||
falsePositives: number;
|
|
||||||
falseNegatives: number;
|
|
||||||
precision: number;
|
|
||||||
recall: number;
|
|
||||||
f1: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One evaluation case's outcome — what {@link TechStepEvalCase.expectedKeys}
|
|
||||||
* said should be found, against what the classifier actually returned for
|
|
||||||
* that case (already mapped from `TechStepMatch.techStepId` back to
|
|
||||||
* `TechStep.key`, see `tech-step-eval.test.ts`).
|
|
||||||
*
|
|
||||||
* Both lists are *multisets*, not sets — a description that names the same
|
|
||||||
* technique twice (rare, but not impossible: "faire cuire, puis... remettre
|
|
||||||
* à cuire") is expected to produce two matches, and comparing as plain sets
|
|
||||||
* would silently treat a classifier that only found one of them as a
|
|
||||||
* perfect match.
|
|
||||||
*/
|
|
||||||
export interface TechStepEvalOutcome {
|
|
||||||
expectedKeys: string[];
|
|
||||||
actualKeys: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** {@link computeTechStepMetrics}'s result — the aggregate across every case, plus a breakdown per technique so a regression hiding behind a healthy overall F1 (one technique's recall collapsing, offset by another's improving) is still visible. */
|
|
||||||
export interface TechStepEvalResult {
|
|
||||||
overall: TechStepMetrics;
|
|
||||||
byKey: Record<string, TechStepMetrics>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawCounts {
|
|
||||||
tp: number;
|
|
||||||
fp: number;
|
|
||||||
fn: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyCounts(): RawCounts {
|
|
||||||
return { tp: 0, fp: 0, fn: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Counts occurrences of each key in a multiset, e.g. `["cook", "cook", "bake"]` -> `{cook: 2, bake: 1}`. */
|
|
||||||
function countByKey(keys: string[]): Map<string, number> {
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
for (const key of keys) {
|
|
||||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
return counts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard vacuous-truth convention for the `0/0` cases: precision defaults
|
|
||||||
* to `1` when nothing was predicted for a key (`tp + fp === 0` — no false
|
|
||||||
* accusation to be precise about), recall defaults to `1` when nothing was
|
|
||||||
* expected (`tp + fn === 0` — nothing to have missed). Neither inflates F1
|
|
||||||
* on its own: a technique the classifier fully misses still has `recall =
|
|
||||||
* 0` (there *were* expected occurrences, just none matched), which is what
|
|
||||||
* pulls F1 down to `0` for that case regardless of precision's vacuous `1`.
|
|
||||||
*/
|
|
||||||
function toMetrics(counts: RawCounts): TechStepMetrics {
|
|
||||||
const { tp, fp, fn } = counts;
|
|
||||||
const precision = tp + fp === 0 ? 1 : tp / (tp + fp);
|
|
||||||
const recall = tp + fn === 0 ? 1 : tp / (tp + fn);
|
|
||||||
const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
|
|
||||||
return { truePositives: tp, falsePositives: fp, falseNegatives: fn, precision, recall, f1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aggregates every {@link TechStepEvalOutcome} into one overall
|
|
||||||
* precision/recall/F1 plus a per-technique breakdown.
|
|
||||||
*
|
|
||||||
* Counted key by key, multiset-style, per outcome: for a given technique,
|
|
||||||
* `min(expectedCount, actualCount)` true positives, any actual occurrences
|
|
||||||
* beyond that are false positives, any expected occurrences short of that
|
|
||||||
* are false negatives — generalizes the usual set-based TP/FP/FN definition
|
|
||||||
* to handle a technique mentioned (or matched) more than once in the same
|
|
||||||
* step without over- or under-counting it.
|
|
||||||
*/
|
|
||||||
export function computeTechStepMetrics(outcomes: TechStepEvalOutcome[]): TechStepEvalResult {
|
|
||||||
const overallCounts = emptyCounts();
|
|
||||||
const countsByKey = new Map<string, RawCounts>();
|
|
||||||
|
|
||||||
for (const outcome of outcomes) {
|
|
||||||
const expectedCounts = countByKey(outcome.expectedKeys);
|
|
||||||
const actualCounts = countByKey(outcome.actualKeys);
|
|
||||||
const allKeys = new Set([...expectedCounts.keys(), ...actualCounts.keys()]);
|
|
||||||
|
|
||||||
for (const key of allKeys) {
|
|
||||||
const expected = expectedCounts.get(key) ?? 0;
|
|
||||||
const actual = actualCounts.get(key) ?? 0;
|
|
||||||
const tp = Math.min(expected, actual);
|
|
||||||
const fp = Math.max(0, actual - expected);
|
|
||||||
const fn = Math.max(0, expected - actual);
|
|
||||||
|
|
||||||
overallCounts.tp += tp;
|
|
||||||
overallCounts.fp += fp;
|
|
||||||
overallCounts.fn += fn;
|
|
||||||
|
|
||||||
const keyCounts = countsByKey.get(key) ?? emptyCounts();
|
|
||||||
keyCounts.tp += tp;
|
|
||||||
keyCounts.fp += fp;
|
|
||||||
keyCounts.fn += fn;
|
|
||||||
countsByKey.set(key, keyCounts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const byKey: Record<string, TechStepMetrics> = {};
|
|
||||||
for (const [key, counts] of countsByKey) {
|
|
||||||
byKey[key] = toMetrics(counts);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { overall: toMetrics(overallCounts), byKey };
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
|
import { NlpManager } from "node-nlp";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import {
|
import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js";
|
||||||
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
|
||||||
|
|
@ -20,27 +15,25 @@ import { intentServiceClient } from "./intent-service-client.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 (originally built on `node-nlp`, now entirely
|
* small hybrid pipeline built on `node-nlp` ({@link TechStepClassifierService}):
|
||||||
* delegated to `services/tech-step-intent-service` — a spaCy-based
|
|
||||||
* microservice, see {@link IntentServiceClient} and that service's own
|
|
||||||
* README):
|
|
||||||
*
|
*
|
||||||
* 1. **NER** (the intent service's `PhraseMatcher`, built from its own
|
* 1. **NER** (node-nlp enum entities, `synonyms` in `TECH_STEP_TRAINING_DATA`)
|
||||||
* `training_data.py`'s `synonyms`) finds every *candidate* technique
|
* finds every *candidate* technique mention in the whole
|
||||||
* mention in the whole description, each with its exact character span —
|
* description, each with its exact character span — mechanically the
|
||||||
* mechanically the same job the old regexes did, just as flat synonym
|
* same job the old regexes did, just as flat synonym lists instead of
|
||||||
* lists instead of hand-written patterns. This step alone is *not* the
|
* hand-written patterns (node-nlp's own stemmer/fuzzy matching already
|
||||||
* final answer — see step 3.
|
* covers minor conjugation/typo variance the regexes had to enumerate
|
||||||
|
* by hand). This step alone is *not* the final answer — see step 3.
|
||||||
* 2. The description is cut into clauses around those candidate spans
|
* 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** (the intent service's `textcat`, trained
|
* 3. **NLP intent classification** (node-nlp's `NlpManager`, trained on
|
||||||
* on its own `training_data.py`'s `utterances`) then classifies each
|
* `TECH_STEP_TRAINING_DATA`'s `utterances`) then classifies each clause
|
||||||
* clause on its own — this is what actually delivers "meaning, not
|
* on its own — this is what actually delivers "meaning, not keywords":
|
||||||
* keywords": the classifier was deliberately trained on paraphrases that
|
* the classifier was deliberately trained on paraphrases that never use
|
||||||
* never use the technique's own verb (e.g. "jusqu'à ce que le beurre ait
|
* 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
|
||||||
|
|
@ -56,12 +49,12 @@ import { intentServiceClient } from "./intent-service-client.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`); this class only ever needs a
|
* `test/tech-step-matcher.test.ts`); the classifier itself needs a one-time
|
||||||
* `TechStep.key -> id` lookup from the DB, memoized on the shared
|
* training pass (`_ensureTrained`, node-nlp's `NlpManager.train()`) plus a
|
||||||
* {@link techStepClassifier} singleton rather than repeated per call — the
|
* `TechStep.key -> id` lookup from the DB, both memoized on the shared
|
||||||
* NLP model itself trains once, inside `services/tech-step-intent-service`'s
|
* {@link techStepClassifier} singleton rather than repeated per call —
|
||||||
* own startup, entirely independently of this class (see that service's
|
* training is the expensive part (a few hundred ms for this corpus), never
|
||||||
* README — this repo no longer pushes any corpus to it over HTTP).
|
* worth redoing per request let alone per step.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -96,15 +89,6 @@ export function normalizeText(text: string): string {
|
||||||
* Persisted as `StepTechStep.start`/`end`/`contextStart`/`contextEnd`
|
* 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;
|
||||||
|
|
@ -112,21 +96,6 @@ 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. */
|
||||||
|
|
@ -272,90 +241,77 @@ 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.
|
||||||
*
|
*
|
||||||
* Recalibrated for the migration off `node-nlp` to
|
* Raised from `0.65` after finding real (non-adversarial) misclassified
|
||||||
* `services/tech-step-intent-service` (spaCy `textcat`, exclusive classes)
|
* clauses that scored just above the old threshold — e.g. English recipe
|
||||||
* — its score distribution is meaningfully different from node-nlp's own
|
* text run through the French classifier (which must find *nothing*,
|
||||||
* classifier, and shifts again every time the corpus' technique count
|
* confirmed by `recipe-translation.test.ts`'s own locale-isolation test)
|
||||||
* changes (more exclusive classes generally means a *lower* natural
|
* scored `0.69` for `boil`, essentially classifier noise on
|
||||||
* confidence ceiling, softmax mass spread thinner).
|
* out-of-vocabulary input rather than a real, confident verdict. The
|
||||||
*
|
* clauses this threshold exists to actually trust score far higher in
|
||||||
* Currently `0.25`, set against the corpus as expanded to ~74 techniques
|
* practice (`0.91`–`1.0` for the real corrected cases found this session)
|
||||||
* (`services/tech-step-intent-service/intent_service/training_data.py`,
|
* — `0.75` sits comfortably above the noise floor and below every genuine
|
||||||
* `_TRAINING_ITERATIONS = 25`, `textcat` trained on each technique's own
|
* match seen so far.
|
||||||
* `synonyms` in addition to its `utterances` — see that constant's own
|
|
||||||
* comment for the calibration history) from manual spot-checks, not yet a
|
|
||||||
* real `calibrate-tech-step-threshold.ts` sweep against
|
|
||||||
* `TECH_STEP_EVAL_DATASET` (needs Postgres — see that script's own doc
|
|
||||||
* comment): observed real-case scores ranged `0.31`-`0.89` (`simmer`
|
|
||||||
* lowest, still correct in argmax and anchored anyway; `melt` highest, the
|
|
||||||
* motivating anchor-less case), against a noise floor around `0.02`
|
|
||||||
* (English text through the French classifier). `0.25` sits with real
|
|
||||||
* margin above the noise floor and below every real case seen so far, but
|
|
||||||
* **this is a placeholder pending the real eval-dataset sweep** — do not
|
|
||||||
* treat it as load-bearing precision the way the original `0.45`
|
|
||||||
* (calibrated against the ~26-technique corpus, `TECH_STEP_EVAL_DATASET`
|
|
||||||
* F1 plateauing exactly there) was.
|
|
||||||
*/
|
*/
|
||||||
export const CONFIDENCE_THRESHOLD = 0.25;
|
const CONFIDENCE_THRESHOLD = 0.75;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One clause's full classification detail — the finer-grained sibling of
|
* Trains and owns the `node-nlp` model behind {@link matchTechStepSpans} —
|
||||||
* {@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 memoized lookup promise), not just grouped stateless helpers.
|
* state (the trained model, the memoized training/lookup promises), not
|
||||||
* The actual NER/intent-classification model lives entirely in
|
* just grouped stateless helpers.
|
||||||
* `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 {
|
||||||
/** 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. */
|
/** node-nlp's manager — both NER (enum entities) and NLP (intent classification) live on the same instance, trained together. */
|
||||||
private _techStepIdsLoaded: Promise<void> | undefined;
|
private readonly _manager: NlpManager;
|
||||||
|
/** Memoized training pass — `undefined` until the first call starts it, after which every caller (concurrent or not) awaits the same promise rather than retraining. */
|
||||||
|
private _trained: Promise<void> | undefined;
|
||||||
|
/** Memoized `TechStep.key -> id` lookup — training data only knows techniques by their stable `uid`/`key`, resolved to the real DB id once, alongside training. */
|
||||||
private _techStepIdByUid: Map<string, number> | undefined;
|
private _techStepIdByUid: Map<string, number> | undefined;
|
||||||
|
|
||||||
/** Same memoized-lookup shape as {@link _techStepIdsLoaded}/{@link _techStepIdByUid}, for `Utensil.key -> id` instead — a `kind: "utensil"` entity from the intent service resolves through this map, never `_techStepIdByUid`. */
|
public constructor() {
|
||||||
private _utensilIdsLoaded: Promise<void> | undefined;
|
this._manager = new NlpManager({
|
||||||
private _utensilIdByUid: Map<string, number> | undefined;
|
languages: ["fr", "en"],
|
||||||
|
forceNER: true,
|
||||||
|
nlu: { log: false },
|
||||||
|
// node-nlp's enum-entity NER defaults to a fuzzy (Levenshtein-based)
|
||||||
|
// 0.8 accuracy threshold — loose enough that e.g. "faire" (the
|
||||||
|
// generic French helper verb in almost every recipe step) fuzzy-
|
||||||
|
// matches `fry`'s synonym "frire" at 0.80, a false positive found
|
||||||
|
// while tuning this against the real training corpus. `1` (exact,
|
||||||
|
// after node-nlp's own case/accent/stemming normalization — real
|
||||||
|
// conjugation variance is still covered by listing each form in
|
||||||
|
// `tech-step-training-data.ts`) removed it without losing any real
|
||||||
|
// match. Precision matters more than recall for this stage — NER
|
||||||
|
// only proposes candidate split points, `_classifyClause`'s trained
|
||||||
|
// model (not fuzzy string distance) is what actually has to be
|
||||||
|
// right.
|
||||||
|
ner: { threshold: 1 },
|
||||||
|
// node-nlp defaults to `autoSave`/`autoLoad: true` — silently
|
||||||
|
// persisting the trained model to a `model.nlp` file in the process's
|
||||||
|
// cwd, and *loading from that file instead of retraining* the next
|
||||||
|
// time a manager is constructed, if the file already exists. Found
|
||||||
|
// this the hard way: a stray `model.nlp` appeared at the repo root
|
||||||
|
// after running this locally. That's the opposite of what this
|
||||||
|
// service wants — `TECH_STEP_TRAINING_DATA` in code is the single
|
||||||
|
// source of truth this always trains fresh from (see this file's own
|
||||||
|
// doc comment) — a stale on-disk model silently shadowing a
|
||||||
|
// corpus/threshold update would be a nasty, hard-to-notice class of
|
||||||
|
// bug. Both off; nothing here should ever touch disk.
|
||||||
|
autoSave: false,
|
||||||
|
autoLoad: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forces the `TechStep.key -> id` lookup to load now, synchronously with
|
* Forces training plus node-nlp's own one-time lazy setup (loading its
|
||||||
* server startup (see `server.ts`, which also retries this against a
|
* bundled per-language stemmers/tokenizers on the *first* real
|
||||||
* not-yet-reachable intent service), rather than stalling whichever
|
* `NlpManager.process()` call takes a few seconds by itself, separate
|
||||||
* request happens to be first to save/preview a recipe. Doesn't wait on
|
* from and much slower than the ~40ms `train()` pass — measured against
|
||||||
* `services/tech-step-intent-service` finishing its own training — that
|
* this corpus while tuning the pipeline) to happen now, synchronously
|
||||||
* service is only ever considered "up" by Docker Compose/CI once it
|
* with server startup (see `server.ts`), rather than stalling whichever
|
||||||
* already is (see that service's `GET /health`), so by the time this
|
* request happens to be first to save/preview a recipe.
|
||||||
* 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 {
|
||||||
|
|
@ -379,35 +335,26 @@ export class TechStepClassifierService {
|
||||||
*/
|
*/
|
||||||
public async matchTechStepSpans(description: string, locale: string): Promise<TechStepMatch[]> {
|
public async matchTechStepSpans(description: string, locale: string): Promise<TechStepMatch[]> {
|
||||||
try {
|
try {
|
||||||
await Promise.all([this._ensureTechStepIdsLoaded(), this._ensureUtensilIdsLoaded()]);
|
await this._ensureTrained();
|
||||||
if (description.trim().length === 0) return [];
|
if (description.trim().length === 0) return [];
|
||||||
|
|
||||||
// Loaded fresh per call (once per step, see `recipe.service.ts`'s
|
const nerResult = await this._manager.process(locale, description);
|
||||||
// `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
|
||||||
.filter((entity) => entity.kind === "technique")
|
// node-nlp's language plugins also auto-extract their own built-in
|
||||||
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
|
// entities (numbers, durations, dates…) alongside the enum
|
||||||
const utensilEntities = nerResult.entities.filter((entity) => entity.kind === "utensil");
|
// entities `_train` registered from `TECH_STEP_TRAINING_DATA` —
|
||||||
|
// `type === "enum"` is what tells the two apart; without this
|
||||||
|
// filter a step like "10 minutes" would hand `splitIntoClauses` a
|
||||||
|
// bogus "duration" candidate that resolves to no real technique.
|
||||||
|
.filter((entity) => entity.type === "enum")
|
||||||
|
.map((entity) => ({
|
||||||
|
uid: entity.entity,
|
||||||
|
start: entity.start,
|
||||||
|
// node-nlp's own `end` is inclusive (verified against a real
|
||||||
|
// trained model) — `+ 1` converts to this module's `[start, end)`
|
||||||
|
// convention, matching `String.prototype.slice`.
|
||||||
|
end: entity.end + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
const clauses = splitIntoClauses(description, candidates);
|
const clauses = splitIntoClauses(description, candidates);
|
||||||
const matches: TechStepMatch[] = [];
|
const matches: TechStepMatch[] = [];
|
||||||
|
|
@ -421,35 +368,12 @@ 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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -464,61 +388,6 @@ export class TechStepClassifierService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Splits `description` into clauses exactly like {@link matchTechStepSpans}
|
|
||||||
* does, but returns each clause's *raw* classification detail
|
|
||||||
* ({@link TechStepClauseClassification}) instead of the threshold-applied,
|
|
||||||
* anchor-fallback-resolved `TechStepMatch` — see that type's doc comment
|
|
||||||
* for why/who needs this. Deliberately a separate traversal rather than a
|
|
||||||
* shared refactor with `matchTechStepSpans`/`_classifyClause`: this method
|
|
||||||
* exists purely to add a new, additive read path without risking a
|
|
||||||
* behavior change to the two already-relied-on methods above.
|
|
||||||
*/
|
|
||||||
public async classifyClauses(
|
|
||||||
description: string,
|
|
||||||
locale: string,
|
|
||||||
): Promise<TechStepClauseClassification[]> {
|
|
||||||
try {
|
|
||||||
await this._ensureTechStepIdsLoaded();
|
|
||||||
if (description.trim().length === 0) return [];
|
|
||||||
|
|
||||||
const nerResult = await intentServiceClient.process(locale, description);
|
|
||||||
const candidates: TechniqueCandidate[] = nerResult.entities
|
|
||||||
.filter((entity) => entity.kind === "technique")
|
|
||||||
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
|
|
||||||
|
|
||||||
const clauses = splitIntoClauses(description, candidates);
|
|
||||||
const results: TechStepClauseClassification[] = [];
|
|
||||||
for (const clause of clauses) {
|
|
||||||
const clauseText = description.slice(clause.start, clause.end).trim();
|
|
||||||
const anchorUid = clause.anchor?.uid ?? null;
|
|
||||||
if (clauseText.length === 0) {
|
|
||||||
results.push({
|
|
||||||
clauseText,
|
|
||||||
start: clause.start,
|
|
||||||
end: clause.end,
|
|
||||||
anchorUid,
|
|
||||||
intentUid: null,
|
|
||||||
score: 0,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const result = await intentServiceClient.process(locale, clauseText);
|
|
||||||
results.push({
|
|
||||||
clauseText,
|
|
||||||
start: clause.start,
|
|
||||||
end: clause.end,
|
|
||||||
anchorUid,
|
|
||||||
intentUid: result.intent,
|
|
||||||
score: result.intent === null ? 0 : result.score,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see matchTechStepSpans()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience wrapper around {@link matchTechStepSpans} for callers that
|
* 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.
|
||||||
|
|
@ -552,8 +421,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 intentServiceClient.process(locale, clauseText);
|
const result = await this._manager.process(locale, clauseText);
|
||||||
if (result.intent !== null && result.score >= CONFIDENCE_THRESHOLD) {
|
if (result.intent !== "None" && result.score >= CONFIDENCE_THRESHOLD) {
|
||||||
return result.intent;
|
return result.intent;
|
||||||
}
|
}
|
||||||
return clause.anchor?.uid ?? null;
|
return clause.anchor?.uid ?? null;
|
||||||
|
|
@ -563,56 +432,52 @@ export class TechStepClassifierService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the `uid -> TechStep.id` lookup exactly once — memoized on
|
* Trains `_manager` from {@link TECH_STEP_TRAINING_DATA} and resolves the
|
||||||
* `_techStepIdsLoaded` so a burst of concurrent calls (several steps of
|
* `uid -> TechStep.id` lookup, both exactly once — memoized on
|
||||||
* the same recipe save, awaited via the same event loop tick) all await
|
* `_trained` so a burst of concurrent calls (several steps of the same
|
||||||
* the one in-flight DB query rather than each firing their own.
|
* recipe save, awaited via the same event loop tick) all await the one
|
||||||
|
* in-flight training pass rather than each kicking off their own.
|
||||||
*/
|
*/
|
||||||
private async _ensureTechStepIdsLoaded(): Promise<void> {
|
private async _ensureTrained(): Promise<void> {
|
||||||
if (this._techStepIdsLoaded === undefined) {
|
if (this._trained === undefined) {
|
||||||
this._techStepIdsLoaded = this._loadTechStepIds();
|
this._trained = this._train();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this._techStepIdsLoaded;
|
await this._trained;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A failed load must be retried by the *next* call, not leave every
|
// A failed training pass must be retried by the *next* call, not
|
||||||
// future call permanently rejecting against a stale failed promise.
|
// leave every future call permanently rejecting against a stale
|
||||||
this._techStepIdsLoaded = undefined;
|
// failed promise.
|
||||||
|
this._trained = undefined;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _loadTechStepIds(): Promise<void> {
|
private async _train(): 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `Utensil.key -> id` counterpart of {@link _ensureTechStepIdsLoaded} — same memoize-once-retry-on-failure shape. */
|
for (const entry of TECH_STEP_TRAINING_DATA) {
|
||||||
private async _ensureUtensilIdsLoaded(): Promise<void> {
|
for (const [locale, data] of [
|
||||||
if (this._utensilIdsLoaded === undefined) {
|
["fr", entry.fr],
|
||||||
this._utensilIdsLoaded = this._loadUtensilIds();
|
["en", entry.en],
|
||||||
}
|
] as const) {
|
||||||
try {
|
if (data.synonyms.length > 0) {
|
||||||
await this._utensilIdsLoaded;
|
this._manager.addNamedEntityText(entry.uid, entry.uid, [locale], data.synonyms);
|
||||||
} catch (err) {
|
}
|
||||||
this._utensilIdsLoaded = undefined;
|
for (const utterance of data.utterances) {
|
||||||
throw err;
|
this._manager.addDocument(locale, utterance, entry.uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async _loadUtensilIds(): Promise<void> {
|
await this._manager.train();
|
||||||
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 — every caller reuses the one memoized `TechStep.key -> id` lookup rather than re-querying the DB. The actual model training (expensive — a couple of minutes, both locales combined) happens entirely inside `services/tech-step-intent-service`'s own startup, not here — see that service's `_TRAINING_ITERATIONS`. */
|
/** Single shared instance — training is expensive enough (a few hundred ms) that every caller must reuse the one already-trained model, never spin up their own. */
|
||||||
export const techStepClassifier = new TechStepClassifierService();
|
export const techStepClassifier = new TechStepClassifierService();
|
||||||
|
|
|
||||||
911
apps/api/src/lib/recipe-matching/tech-step-training-data.ts
Normal file
911
apps/api/src/lib/recipe-matching/tech-step-training-data.ts
Normal file
|
|
@ -0,0 +1,911 @@
|
||||||
|
/**
|
||||||
|
* Training corpus for {@link TechStepClassifierService} (`tech-step-matcher.ts`)
|
||||||
|
* — one entry per `TechStep` (`uid` matches `reference-seed-data.ts`'s
|
||||||
|
* `TECH_STEPS`, which still owns the reference `TechStep` rows themselves;
|
||||||
|
* this file replaces `TECH_STEPS[].mappings`' regex expressions as the
|
||||||
|
* *matching* data source).
|
||||||
|
*
|
||||||
|
* Two distinct kinds of content per technique/locale, feeding two distinct
|
||||||
|
* mechanisms of the classifier (see that file's doc comment for why both
|
||||||
|
* are needed):
|
||||||
|
*
|
||||||
|
* - `synonyms` — short literal words/set phrases, fed to node-nlp's NER
|
||||||
|
* (enum entities). Mechanically equivalent to the old regexes' verb-form
|
||||||
|
* alternations, just spelled out as plain words instead of a pattern
|
||||||
|
* (node-nlp's own stemmer/fuzzy matching already covers minor
|
||||||
|
* conjugation/typo variance that the regexes had to enumerate by hand).
|
||||||
|
* Used only to find *candidate* technique mentions and cut a step into
|
||||||
|
* clauses around them — never the final answer on their own.
|
||||||
|
* - `utterances` — full example clauses, fed to node-nlp's NLP Manager as
|
||||||
|
* training documents for the intent classifier. Deliberately mixes
|
||||||
|
* keyword-anchored phrasings (reinforces the obvious case) with
|
||||||
|
* paraphrases that never use the technique's own verb at all (e.g.
|
||||||
|
* "jusqu'à ce que le beurre ait disparu" for `melt`) — this second kind
|
||||||
|
* is what actually delivers on "comprendre le sens, pas juste les mots
|
||||||
|
* clés" (see the PR this file was introduced in): a clause reaching the
|
||||||
|
* classifier gets labeled by what it's trained to recognize as *meaning*
|
||||||
|
* this technique, not by which literal word triggered its extraction.
|
||||||
|
*
|
||||||
|
* Kept as static in-code data (not DB rows, unlike the old
|
||||||
|
* `TechStepMapping` table) because nothing needs to query/edit it at
|
||||||
|
* runtime — it only ever feeds one thing, the classifier's one-time
|
||||||
|
* training pass (see `TechStepClassifierService._ensureTrained`) — same
|
||||||
|
* reasoning `INGREDIENT_LABELS_EN` (`packages/shared`) is a plain object,
|
||||||
|
* not a database table.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One technique's matching data for one locale — see this file's doc comment for what each list feeds. */
|
||||||
|
export interface TechStepLocaleTrainingData {
|
||||||
|
synonyms: string[];
|
||||||
|
utterances: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One technique's full training entry — `uid` must match a `TECH_STEPS[].uid` in `reference-seed-data.ts`. */
|
||||||
|
export interface TechStepTrainingEntry {
|
||||||
|
uid: string;
|
||||||
|
fr: TechStepLocaleTrainingData;
|
||||||
|
en: TechStepLocaleTrainingData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
||||||
|
{
|
||||||
|
uid: "cook",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"cuire",
|
||||||
|
"cuisez",
|
||||||
|
"cuisant",
|
||||||
|
"cuisson",
|
||||||
|
"cuit",
|
||||||
|
"cuite",
|
||||||
|
"cuites",
|
||||||
|
"cuits",
|
||||||
|
"cuisiner",
|
||||||
|
"cuisinez",
|
||||||
|
"cuisiné",
|
||||||
|
"cuisinée",
|
||||||
|
"faire cuire",
|
||||||
|
"laisser cuire",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"faire cuire à feu moyen",
|
||||||
|
"laisser cuire jusqu'à ce que ce soit prêt",
|
||||||
|
"la cuisson dure environ dix minutes",
|
||||||
|
"jusqu'à ce que la viande ne soit plus rose au centre",
|
||||||
|
"poursuivre la cuisson à couvert",
|
||||||
|
// Two real recipe clauses found misclassified (as `preheat` and
|
||||||
|
// `panFry` respectively, both above the confidence threshold) once
|
||||||
|
// real, longer, comma-heavy sentences started reaching the
|
||||||
|
// classifier — neither error came from a missing keyword (both
|
||||||
|
// clauses' own NER anchor, "laisser cuire"/"faire cuire", was
|
||||||
|
// already right), just the classifier's low-heat/occasional-
|
||||||
|
// stirring phrasing not resembling anything short and clean-cut it
|
||||||
|
// had actually been trained on.
|
||||||
|
"baisser le feu et laisser cuire à découvert encore un quart d'heure",
|
||||||
|
"faire cuire à feu doux en remuant de temps en temps",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// NOT "cooked through"/"cooking through" — both are word-prefix
|
||||||
|
// extensions of "cooked"/"cooking" above, so any text containing them
|
||||||
|
// matches BOTH the short and long form as separate overlapping NER
|
||||||
|
// candidates, corrupting clause-splitting (confirmed via "It should
|
||||||
|
// be cooking through evenly", which spuriously grew a second,
|
||||||
|
// wrongly-classified `roast` candidate). See this pattern flagged
|
||||||
|
// throughout the file wherever it was found — the fix is always to
|
||||||
|
// drop the longer, redundant form rather than keep both.
|
||||||
|
synonyms: ["cook", "cooks", "cooked", "cooking"],
|
||||||
|
utterances: [
|
||||||
|
"cook over medium heat",
|
||||||
|
"cook until done",
|
||||||
|
"cooking takes about ten minutes",
|
||||||
|
"until no longer pink in the middle",
|
||||||
|
"continue cooking covered",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "fry",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"frire",
|
||||||
|
"frit",
|
||||||
|
"frite",
|
||||||
|
"frites",
|
||||||
|
"friture",
|
||||||
|
"faire frire",
|
||||||
|
"faites frire",
|
||||||
|
"bain de friture",
|
||||||
|
"huile de friture",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"faire frire dans l'huile chaude",
|
||||||
|
"plonger dans la friture",
|
||||||
|
"jusqu'à ce que ce soit doré et croustillant à l'extérieur",
|
||||||
|
"l'huile doit être bien chaude avant d'y plonger les morceaux",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// NOT "frying oil" — a word-prefix extension of "frying" above (see
|
||||||
|
// the `cook` entry's comment for why that duplicates/corrupts NER
|
||||||
|
// candidates; here it was even worse, misclassifying as `preheat`).
|
||||||
|
synonyms: ["fry", "fries", "fried", "frying", "deep fry", "deep-fried", "deep frying"],
|
||||||
|
utterances: [
|
||||||
|
"fry in hot oil",
|
||||||
|
"deep fry until golden",
|
||||||
|
"until crisp and golden on the outside",
|
||||||
|
"the oil should be very hot before adding the pieces",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "melt",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"fondre",
|
||||||
|
"fondu",
|
||||||
|
"fondue",
|
||||||
|
"fondues",
|
||||||
|
"faire fondre",
|
||||||
|
"faites fondre",
|
||||||
|
// Also a plausible way to say "melt" (heating something — usually
|
||||||
|
// a fat — until it liquefies), not just a `preheat` phrasing —
|
||||||
|
// restores what the regex-based system anchored on before this
|
||||||
|
// pipeline replaced it.
|
||||||
|
"faire chauffer",
|
||||||
|
"faites chauffer",
|
||||||
|
"liquéfier",
|
||||||
|
"liquéfiez",
|
||||||
|
"liquéfié",
|
||||||
|
"faire liquéfier",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"faire fondre le beurre",
|
||||||
|
"jusqu'à ce que le beurre ait disparu dans la poêle",
|
||||||
|
"le beurre doit être complètement liquide",
|
||||||
|
"laisser le fromage devenir tout liquide sur feu doux",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: ["melt", "melts", "melted", "melting", "liquefy", "liquefied"],
|
||||||
|
utterances: [
|
||||||
|
"melt the butter",
|
||||||
|
"until the butter has completely disappeared into the pan",
|
||||||
|
"the butter should be fully liquid",
|
||||||
|
"let the cheese turn completely liquid over low heat",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "deglaze",
|
||||||
|
fr: {
|
||||||
|
// NOT "déglacer la poêle"/"déglacer le fond de cuisson" — both are
|
||||||
|
// word-prefix extensions of "déglacer" above (see `cook`'s comment
|
||||||
|
// for why that duplicates NER candidates).
|
||||||
|
synonyms: ["déglacer", "déglacez", "déglacé", "déglacée", "déglaçage"],
|
||||||
|
utterances: [
|
||||||
|
"déglacer avec le vin blanc",
|
||||||
|
"verser le vin dans la poêle chaude pour décoller les sucs",
|
||||||
|
"gratter les sucs de cuisson au fond de la casserole avec un peu de bouillon",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// NOT "deglaze the pan" — a word-prefix extension of "deglaze" above
|
||||||
|
// (see `cook`'s comment for why that duplicates NER candidates).
|
||||||
|
synonyms: ["deglaze", "deglazes", "deglazed", "deglazing", "lift the browned bits"],
|
||||||
|
utterances: [
|
||||||
|
"deglaze with white wine",
|
||||||
|
"pour the wine into the hot pan to lift the browned bits",
|
||||||
|
"scrape up the browned bits at the bottom of the pan with a splash of stock",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "simmer",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"mijoter",
|
||||||
|
"mijotez",
|
||||||
|
"mijote",
|
||||||
|
"mijotant",
|
||||||
|
"mijoté",
|
||||||
|
"frémir",
|
||||||
|
"frémissant",
|
||||||
|
"frémissante",
|
||||||
|
"à petit feu",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"laisser mijoter à feu doux",
|
||||||
|
"faire mijoter pendant une heure",
|
||||||
|
"de petites bulles doivent remonter doucement à la surface",
|
||||||
|
"laisser cuire tout doucement à couvert pendant longtemps",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// NOT "simmering gently" — a word-prefix extension of "simmering"
|
||||||
|
// above (see `cook`'s comment for why that duplicates NER candidates).
|
||||||
|
synonyms: ["simmer", "simmers", "simmered", "simmering", "gentle simmer", "low simmer"],
|
||||||
|
utterances: [
|
||||||
|
"let it simmer over low heat",
|
||||||
|
"simmer for one hour",
|
||||||
|
"small bubbles should gently rise to the surface",
|
||||||
|
"let it cook very gently, covered, for a long time",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "boil",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"bouillir",
|
||||||
|
"bouillant",
|
||||||
|
"bouillie",
|
||||||
|
"bouillies",
|
||||||
|
"ébullition",
|
||||||
|
"porter à ébullition",
|
||||||
|
"gros bouillons",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"porter à ébullition",
|
||||||
|
"faire bouillir l'eau",
|
||||||
|
"de grosses bulles doivent agiter la surface avec force",
|
||||||
|
"jusqu'à ce que ça bouillonne franchement",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// NOT "boiling point" — a word-prefix extension of "boiling" above
|
||||||
|
// (see `cook`'s comment for why that duplicates NER candidates).
|
||||||
|
synonyms: ["boil", "boils", "boiled", "boiling", "rolling boil"],
|
||||||
|
utterances: [
|
||||||
|
"bring to a boil",
|
||||||
|
"boil the water",
|
||||||
|
"large bubbles should be vigorously breaking the surface",
|
||||||
|
"until it's rolling vigorously",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "roast",
|
||||||
|
fr: {
|
||||||
|
// NOT "rôti au four" — a word-prefix extension of "rôti" above (see
|
||||||
|
// `cook`'s comment for why that duplicates NER candidates).
|
||||||
|
synonyms: ["rôtir", "rôti", "rôtie", "rôties", "rôtis", "rôtissage"],
|
||||||
|
utterances: [
|
||||||
|
"faire rôtir la volaille entière",
|
||||||
|
"le rôti doit dorer uniformément de tous les côtés",
|
||||||
|
"cuire la pièce de viande entière au four à chaleur sèche",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: ["roast", "roasts", "roasted", "roasting", "oven-roast", "oven roasted"],
|
||||||
|
utterances: [
|
||||||
|
"roast the whole bird",
|
||||||
|
"it should brown evenly on every side",
|
||||||
|
"cook the whole piece of meat in dry oven heat",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "grill",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"griller",
|
||||||
|
"grillez",
|
||||||
|
"grillé",
|
||||||
|
"grillée",
|
||||||
|
"grillées",
|
||||||
|
"grillade",
|
||||||
|
"grillades",
|
||||||
|
"barbecue",
|
||||||
|
"au barbecue",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"faire griller sur la grille du barbecue",
|
||||||
|
"marquer les steaks sur une plaque brûlante",
|
||||||
|
"des traces de quadrillage doivent apparaître à la cuisson",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: ["grill", "grills", "grilled", "grilling", "barbecue", "char-grill", "charbroiled"],
|
||||||
|
utterances: [
|
||||||
|
"grill on the barbecue rack",
|
||||||
|
"sear the steaks on a scorching-hot plate",
|
||||||
|
"char marks should appear as it cooks",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "panFry",
|
||||||
|
fr: {
|
||||||
|
// Deliberately NOT "poêlé"/"poêlée"/"poêlés" here, despite reading
|
||||||
|
// like natural panFry vocabulary: node-nlp's French stemmer reduces
|
||||||
|
// them to the same root as the bare noun "poêle" (a pan), so
|
||||||
|
// registering them made every plain mention of "poêle" — e.g.
|
||||||
|
// `preheat`'s own "la poêle" — a false-positive panFry candidate too.
|
||||||
|
// Found via the "jusqu'à ce que le beurre ait disparu dans la poêle"
|
||||||
|
// regression test, which unexpectedly grew a spurious panFry match.
|
||||||
|
synonyms: ["sauter", "sautez", "sauté", "sautée", "sautées", "sautant", "à la poêle"],
|
||||||
|
utterances: [
|
||||||
|
"faire sauter les légumes à la poêle",
|
||||||
|
"saisir rapidement à feu vif en remuant sans cesse",
|
||||||
|
"faire revenir en remuant vivement dans une poêle très chaude",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: [
|
||||||
|
"sauté",
|
||||||
|
"sauteed",
|
||||||
|
"sautéed",
|
||||||
|
"sauteing",
|
||||||
|
"pan-fry",
|
||||||
|
"pan fried",
|
||||||
|
"pan-fried",
|
||||||
|
"stir-fry",
|
||||||
|
"pan searing",
|
||||||
|
"seared in a pan",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"sauté the vegetables in a pan",
|
||||||
|
"quickly sear over high heat, stirring constantly",
|
||||||
|
"cook briskly, stirring, in a very hot pan",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "blanch",
|
||||||
|
fr: {
|
||||||
|
synonyms: ["blanchir", "blanchissez", "blanchi", "blanchie", "blanchies", "blanchiment"],
|
||||||
|
utterances: [
|
||||||
|
"faire blanchir les légumes deux minutes dans l'eau bouillante",
|
||||||
|
"plonger brièvement dans l'eau bouillante puis directement dans l'eau glacée",
|
||||||
|
"cuire très rapidement à l'eau bouillante avant de stopper la cuisson au froid",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// "parboil" is folded in here rather than kept a separate technique —
|
||||||
|
// in home-cooking usage (as opposed to professional usage, where they
|
||||||
|
// can differ) it names the same "briefly pre-cook in boiling water"
|
||||||
|
// move blanching does.
|
||||||
|
synonyms: [
|
||||||
|
"blanch",
|
||||||
|
"blanches",
|
||||||
|
"blanched",
|
||||||
|
"blanching",
|
||||||
|
"parboil",
|
||||||
|
"parboiled",
|
||||||
|
"parboiling",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"blanch the vegetables for two minutes in boiling water",
|
||||||
|
"briefly plunge into boiling water then straight into ice water",
|
||||||
|
"cook very quickly in boiling water before stopping it cold",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "marinate",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"mariner",
|
||||||
|
"marinez",
|
||||||
|
"mariné",
|
||||||
|
"marinée",
|
||||||
|
"marinées",
|
||||||
|
"marinade",
|
||||||
|
"macérer",
|
||||||
|
"macérez",
|
||||||
|
"macération",
|
||||||
|
"faire mariner",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"laisser mariner la viande toute la nuit au réfrigérateur",
|
||||||
|
"faire tremper dans la sauce plusieurs heures avant cuisson pour parfumer",
|
||||||
|
"laisser reposer dans le mélange d'huile et d'épices avant de cuisiner",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// NOT "marinating for" — a word-prefix extension of "marinating"
|
||||||
|
// above (see `cook`'s comment for why that duplicates NER candidates
|
||||||
|
// — here it was even worse, misclassifying as `simmer`).
|
||||||
|
synonyms: [
|
||||||
|
"marinate",
|
||||||
|
"marinates",
|
||||||
|
"marinated",
|
||||||
|
"marinating",
|
||||||
|
"marinade",
|
||||||
|
"soak in the marinade",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"let the meat marinate overnight in the fridge",
|
||||||
|
"soak in the sauce for several hours before cooking to flavor it",
|
||||||
|
"let it sit in the oil and spice mixture before cooking",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "chop",
|
||||||
|
fr: {
|
||||||
|
// NOT "hacher grossièrement" — a word-prefix extension of "hacher"
|
||||||
|
// above (see `cook`'s comment for why that duplicates NER candidates).
|
||||||
|
synonyms: [
|
||||||
|
"hacher",
|
||||||
|
"hachez",
|
||||||
|
"haché",
|
||||||
|
"hachée",
|
||||||
|
"hachées",
|
||||||
|
"hachis",
|
||||||
|
"couper en morceaux",
|
||||||
|
"tailler en morceaux",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"hacher finement les oignons",
|
||||||
|
"couper en tout petits morceaux irréguliers au couteau",
|
||||||
|
"réduire les herbes en petits fragments avant de les ajouter",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// NOT "chop coarsely" — a word-prefix extension of "chop" above (see
|
||||||
|
// `cook`'s comment for why that duplicates NER candidates).
|
||||||
|
synonyms: ["chop", "chops", "chopped", "chopping", "roughly chop", "coarsely chopped"],
|
||||||
|
utterances: [
|
||||||
|
"finely chop the onions",
|
||||||
|
"cut into small, uneven pieces with a knife",
|
||||||
|
"break the herbs down into small bits before adding them",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "peel",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"éplucher",
|
||||||
|
"épluchez",
|
||||||
|
"épluché",
|
||||||
|
"épluchée",
|
||||||
|
"épluchées",
|
||||||
|
"épluchage",
|
||||||
|
"peler",
|
||||||
|
"pelez",
|
||||||
|
"pelé",
|
||||||
|
"pelée",
|
||||||
|
"pelées",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"éplucher les pommes de terre",
|
||||||
|
"retirer la peau des carottes avec un économe",
|
||||||
|
"ôter la pelure du fruit avant de le couper",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: ["peel", "peels", "peeled", "peeling", "pare", "pared", "paring"],
|
||||||
|
utterances: [
|
||||||
|
"peel the potatoes",
|
||||||
|
"remove the skin from the carrots with a peeler",
|
||||||
|
"take the skin off the fruit before cutting it",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "mince",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"émincer",
|
||||||
|
"émincez",
|
||||||
|
"émincé",
|
||||||
|
"émincée",
|
||||||
|
"émincées",
|
||||||
|
"ciseler",
|
||||||
|
"ciselez",
|
||||||
|
"ciselé",
|
||||||
|
"ciselée",
|
||||||
|
"ciselées",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"émincer l'oignon en fines lamelles",
|
||||||
|
"couper en très fines tranches régulières",
|
||||||
|
"détailler en lamelles aussi fines que possible",
|
||||||
|
// Without this, a short clause naming a different vegetable —
|
||||||
|
// "Émincer les tomates" — scored just above `melt`'s confidence
|
||||||
|
// threshold instead (a training-set-composition side effect of
|
||||||
|
// adding utterances elsewhere in this same pass, found by the full
|
||||||
|
// regression suite). A second example anchored on a different noun
|
||||||
|
// widens `mince`'s own region enough to reclaim it.
|
||||||
|
"émincer les tomates en fines rondelles",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// NOT "mince finely" — a word-prefix extension of "mince" above (see
|
||||||
|
// `cook`'s comment for why that duplicates NER candidates).
|
||||||
|
synonyms: ["mince", "minces", "minced", "mincing", "thinly slice", "finely mince"],
|
||||||
|
utterances: [
|
||||||
|
"mince the onion into thin strips",
|
||||||
|
"cut into very thin, even slices",
|
||||||
|
"slice into strips as thin as possible",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "mix",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"mélanger",
|
||||||
|
"mélangez",
|
||||||
|
"mélangé",
|
||||||
|
"mélangée",
|
||||||
|
"mélangées",
|
||||||
|
"mélange",
|
||||||
|
"brasser",
|
||||||
|
"brassez",
|
||||||
|
"amalgamer",
|
||||||
|
"amalgamez",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"mélanger tous les ingrédients dans un saladier",
|
||||||
|
"combiner le sucre et la farine ensemble",
|
||||||
|
"remuer jusqu'à obtenir une préparation homogène",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: [
|
||||||
|
"mix",
|
||||||
|
"mixes",
|
||||||
|
"mixed",
|
||||||
|
"mixing",
|
||||||
|
"combine",
|
||||||
|
"combined",
|
||||||
|
"blend",
|
||||||
|
"blended",
|
||||||
|
"blending",
|
||||||
|
"stir together",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"mix all the ingredients in a bowl",
|
||||||
|
"combine the sugar and flour together",
|
||||||
|
"stir until the mixture is smooth and even",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "whisk",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"fouetter",
|
||||||
|
"fouettez",
|
||||||
|
"fouetté",
|
||||||
|
"fouettée",
|
||||||
|
"fouettées",
|
||||||
|
"au fouet",
|
||||||
|
"battre au fouet",
|
||||||
|
"monter au fouet",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"fouetter les œufs et le sucre",
|
||||||
|
"battre vigoureusement au fouet jusqu'à ce que ça blanchisse",
|
||||||
|
"travailler énergiquement pour incorporer de l'air au mélange",
|
||||||
|
// Without these, "Fouetter les blancs en neige" misclassified as
|
||||||
|
// `foldIn` — its own training utterance below also happens to say
|
||||||
|
// "les blancs en neige", and node-nlp's intent classifier leaned on
|
||||||
|
// that shared noun phrase over the actual verb. The exact phrase
|
||||||
|
// itself is needed (not just a paraphrase of it) — a longer,
|
||||||
|
// differently-worded utterance alone wasn't enough to outweigh
|
||||||
|
// `foldIn`'s own close phrasing.
|
||||||
|
"fouetter les blancs en neige",
|
||||||
|
"fouetter les blancs en neige jusqu'à ce qu'ils soient fermes",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: ["whisk", "whisks", "whisked", "whisking", "beat", "whip", "whipped", "whipping"],
|
||||||
|
utterances: [
|
||||||
|
"whisk the eggs and sugar",
|
||||||
|
"beat vigorously with a whisk until pale",
|
||||||
|
"work it briskly to whip air into the mixture",
|
||||||
|
"whisk the egg whites until stiff peaks form",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "foldIn",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"incorporer",
|
||||||
|
"incorporez",
|
||||||
|
"incorporé",
|
||||||
|
"incorporée",
|
||||||
|
"incorporées",
|
||||||
|
// NOT "incorporer délicatement" — it's a superstring of "incorporer"
|
||||||
|
// above, so both would match the same text and hand
|
||||||
|
// `splitIntoClauses` two overlapping candidates for one mention
|
||||||
|
// (found via "Incorporer délicatement la farine" producing two
|
||||||
|
// duplicate matches instead of one).
|
||||||
|
"mélanger délicatement",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"incorporer délicatement les blancs en neige",
|
||||||
|
"ajouter en soulevant doucement la masse pour ne pas casser les bulles",
|
||||||
|
"mélanger tout doucement de bas en haut pour garder l'air emprisonné",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: ["fold in", "folds in", "folded in", "folding in", "gently fold", "fold gently"],
|
||||||
|
utterances: [
|
||||||
|
"gently fold in the beaten egg whites",
|
||||||
|
"add by gently lifting the batter so you don't knock the air out",
|
||||||
|
"very gently stir from the bottom up to keep the air trapped in",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "setAside",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"réserver",
|
||||||
|
"réservez",
|
||||||
|
"réservé",
|
||||||
|
"réservée",
|
||||||
|
"réservées",
|
||||||
|
"mettre de côté",
|
||||||
|
"laisser de côté",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"réserver au frais en attendant",
|
||||||
|
"mettre de côté pour plus tard",
|
||||||
|
"laisser attendre sur le plan de travail pendant la préparation du reste",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: ["set aside", "sets aside", "setting aside", "set it aside", "reserve", "reserved"],
|
||||||
|
utterances: [
|
||||||
|
"set aside in the fridge for now",
|
||||||
|
"put it aside for later",
|
||||||
|
"let it wait on the counter while you prepare the rest",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "season",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"assaisonner",
|
||||||
|
"assaisonnez",
|
||||||
|
"assaisonné",
|
||||||
|
"assaisonnée",
|
||||||
|
"assaisonnement",
|
||||||
|
"relever",
|
||||||
|
"relevez",
|
||||||
|
"épicer",
|
||||||
|
"épicez",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"assaisonner avec du sel et du poivre",
|
||||||
|
"rectifier le goût en ajoutant des épices",
|
||||||
|
"ajouter du sel selon votre goût avant de servir",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: ["season", "seasons", "seasoned", "seasoning", "spice it up", "add seasoning"],
|
||||||
|
utterances: [
|
||||||
|
"season with salt and pepper",
|
||||||
|
"adjust the taste by adding spices",
|
||||||
|
"add salt to taste before serving",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "drain",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"égoutter",
|
||||||
|
"égouttez",
|
||||||
|
"égoutté",
|
||||||
|
"égouttée",
|
||||||
|
"égouttées",
|
||||||
|
"essorer",
|
||||||
|
"essorez",
|
||||||
|
"essoré",
|
||||||
|
"essorée",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"égoutter les pâtes dans une passoire",
|
||||||
|
"verser dans une passoire pour retirer l'eau de cuisson",
|
||||||
|
"laisser l'excédent d'eau s'écouler avant de servir",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: ["drain", "drains", "drained", "draining", "strain", "strained", "straining"],
|
||||||
|
utterances: [
|
||||||
|
"drain the pasta in a colander",
|
||||||
|
"pour into a colander to remove the cooking water",
|
||||||
|
"let the excess water run off before serving",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "brown",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"faire revenir",
|
||||||
|
"faites revenir",
|
||||||
|
"faire dorer",
|
||||||
|
"faites dorer",
|
||||||
|
"colorer",
|
||||||
|
"colorez",
|
||||||
|
"faire colorer",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"faire revenir les oignons dans l'huile chaude",
|
||||||
|
"faire dorer la viande sur toutes les faces",
|
||||||
|
"saisir jusqu'à ce que la surface prenne une belle couleur caramel",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// Verb forms only (not bare "brown"), same reasoning the old regex
|
||||||
|
// doc comment gave — a bare "brown" false-positives on ingredient
|
||||||
|
// descriptions like "brown sugar"/"brown rice", which never get to
|
||||||
|
// the classifier since they're not step text, but keeping the
|
||||||
|
// synonym itself anchored costs nothing and stays consistent.
|
||||||
|
synonyms: ["browned", "browning"],
|
||||||
|
utterances: [
|
||||||
|
"brown the onions in hot oil",
|
||||||
|
"brown the meat on every side",
|
||||||
|
"sear until the surface turns a deep caramel color",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "rest",
|
||||||
|
fr: {
|
||||||
|
synonyms: ["reposer", "laisser reposer", "laissez reposer", "temps de repos"],
|
||||||
|
utterances: [
|
||||||
|
"laisser reposer la pâte trente minutes",
|
||||||
|
"laisser la viande se détendre hors du four avant de la découper",
|
||||||
|
"attendre quelques minutes avant de servir pour que les jus se répartissent",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// Anchored to "let ... rest"/"rest for" rather than bare "rest",
|
||||||
|
// same false-positive reasoning as `brown` above ("the rest of the").
|
||||||
|
synonyms: ["let it rest", "let them rest", "resting for", "rested for", "resting time"],
|
||||||
|
utterances: [
|
||||||
|
"let the dough rest for thirty minutes",
|
||||||
|
"let the meat relax outside the oven before carving it",
|
||||||
|
"wait a few minutes before serving so the juices redistribute",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "preheat",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"préchauffer",
|
||||||
|
"préchauffez",
|
||||||
|
"préchauffé",
|
||||||
|
"préchauffée",
|
||||||
|
// A pan already described as hot ("poêle chaude") implies it's
|
||||||
|
// been preheated, without the verb itself — the classic "Dans une
|
||||||
|
// poêle chaude, faire chauffer une noix de beurre" case (both
|
||||||
|
// `preheat` and `melt` in one instruction).
|
||||||
|
"poêle chaude",
|
||||||
|
"préchauffage",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"préchauffer le four à 180 degrés",
|
||||||
|
"mettre le four à chauffer avant d'y placer le plat",
|
||||||
|
"allumer le four à l'avance pour qu'il soit à température",
|
||||||
|
// A pan gets preheated too, not just an oven — without an example
|
||||||
|
// like this, "poêle" (which also appears throughout `panFry`'s own
|
||||||
|
// training utterances) biased the classifier toward `panFry` for
|
||||||
|
// any preheating clause that happens to mention a pan, found while
|
||||||
|
// testing against the classic "Préchauffer la poêle, puis faire
|
||||||
|
// fondre le beurre" case.
|
||||||
|
"préchauffer la poêle avant d'y verser l'huile",
|
||||||
|
"faire chauffer la poêle à vide quelques minutes",
|
||||||
|
// "poêle" + "feu vif" together still read as `panFry` (the act of
|
||||||
|
// actually cooking something in it) rather than `preheat` (getting
|
||||||
|
// it hot beforehand, nothing in it yet) without an example this
|
||||||
|
// close to that exact wording — found via "mettre la poêle sur feu
|
||||||
|
// vif" (no food mentioned at all) still classifying as panFry.
|
||||||
|
"mettre la poêle vide sur feu vif avant d'ajouter quoi que ce soit",
|
||||||
|
"mettre la poêle sur feu vif",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// NOT "preheating time" — a word-prefix extension of "preheating"
|
||||||
|
// above (see `cook`'s comment for why that duplicates NER candidates).
|
||||||
|
synonyms: ["preheat", "preheats", "preheated", "preheating", "hot pan"],
|
||||||
|
utterances: [
|
||||||
|
"preheat the oven to 180 degrees",
|
||||||
|
"turn the oven on to heat up before putting the dish in",
|
||||||
|
"switch the oven on ahead of time so it's up to temperature",
|
||||||
|
"preheat the pan before adding the oil",
|
||||||
|
"heat the empty pan for a few minutes first",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "bake",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"cuire au four",
|
||||||
|
"cuisson au four",
|
||||||
|
"enfourner",
|
||||||
|
"enfournez",
|
||||||
|
"au four",
|
||||||
|
"enfourné",
|
||||||
|
"enfournée",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"enfourner pendant quarante-cinq minutes",
|
||||||
|
"mettre au four jusqu'à ce que ce soit doré",
|
||||||
|
"cuire dans le four préchauffé jusqu'à ce que la surface soit ferme",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// NOT "baked in the oven" — a word-prefix extension of "baked" above
|
||||||
|
// (see `cook`'s comment for why that duplicates NER candidates).
|
||||||
|
synonyms: ["bake", "bakes", "baked", "baking", "in the oven", "oven-baked"],
|
||||||
|
utterances: [
|
||||||
|
"bake for forty-five minutes",
|
||||||
|
"put it in the oven until golden",
|
||||||
|
"cook in the preheated oven until the surface is firm",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "plate",
|
||||||
|
fr: {
|
||||||
|
// NOT "dressage de l'assiette" — a word-prefix extension of
|
||||||
|
// "dressage" above (see `cook`'s comment for why that duplicates NER
|
||||||
|
// candidates).
|
||||||
|
synonyms: ["dresser", "dressez", "dressage", "disposer dans l'assiette"],
|
||||||
|
utterances: [
|
||||||
|
"dresser harmonieusement dans les assiettes",
|
||||||
|
"disposer joliment sur l'assiette avant de servir",
|
||||||
|
"présenter avec soin au centre de l'assiette",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// NOT "plate up"/"plated nicely" — both are word-prefix extensions of
|
||||||
|
// "plate"/"plated" above (see `cook`'s comment for why that
|
||||||
|
// duplicates NER candidates).
|
||||||
|
synonyms: ["plate", "plates", "plated", "plating"],
|
||||||
|
utterances: [
|
||||||
|
"plate it up nicely",
|
||||||
|
"arrange it neatly on the plate before serving",
|
||||||
|
"present it carefully in the center of the plate",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: "coat",
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"napper",
|
||||||
|
"nappez",
|
||||||
|
"nappé",
|
||||||
|
"nappée",
|
||||||
|
"nappées",
|
||||||
|
"nappage",
|
||||||
|
"enrober",
|
||||||
|
"enrobez",
|
||||||
|
"enrobé",
|
||||||
|
"enrobée",
|
||||||
|
"enrobées",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"napper le gâteau de chocolat fondu",
|
||||||
|
"recouvrir uniformément d'une fine couche de sauce",
|
||||||
|
"verser la sauce par-dessus pour bien enrober",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// NOT "coat evenly" — a word-prefix extension of "coat" above (see
|
||||||
|
// `cook`'s comment for why that duplicates NER candidates).
|
||||||
|
synonyms: ["coat", "coats", "coated", "coating", "dredge", "dredged", "dredging"],
|
||||||
|
utterances: [
|
||||||
|
"coat the cake with melted chocolate",
|
||||||
|
"cover evenly with a thin layer of sauce",
|
||||||
|
"pour the sauce over it so it's well covered",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import { timingSafeEqual } from "node:crypto";
|
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
|
||||||
import type { NextFunction, Request, Response } from "express";
|
|
||||||
import { env } from "../config/env.js";
|
|
||||||
|
|
||||||
/** Header `services/tech-step-llm-worker` sends its shared secret on. Not `Authorization`/a bearer scheme — this isn't a user session, just one internal caller authenticating to another, same "one flat shared secret" shape as e.g. a webhook signing header. */
|
|
||||||
const INTERNAL_WORKER_SECRET_HEADER = "x-internal-worker-secret";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Express middleware guarding `/internal/tech-steps/*` — the surface
|
|
||||||
* `services/tech-step-llm-worker` (a process outside this monorepo, no
|
|
||||||
* Prisma access of its own, see that service's own README) reads
|
|
||||||
* low-confidence NLP clauses and pending `StepTechStepCorrection`s from,
|
|
||||||
* and posts `TechStepTrainingSuggestion`s back to. Never reachable by an
|
|
||||||
* end user's session cookie — deliberately a *different* auth mechanism
|
|
||||||
* than {@link requireAuth} (`require-auth.ts`), not layered on top of it,
|
|
||||||
* since the worker has no `UserProfile`/session of its own to authenticate
|
|
||||||
* as.
|
|
||||||
*
|
|
||||||
* Fails closed: an unset `INTERNAL_WORKER_SECRET` (the default in any
|
|
||||||
* environment that doesn't run the worker, see `config/env.ts`) rejects
|
|
||||||
* every request rather than leaving the surface open, same posture as a
|
|
||||||
* misconfigured `JWT_SECRET` would if it had a working fallback.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `401 NOT_AUTHENTICATED` if the header is missing,
|
|
||||||
* wrong, or the server has no secret configured at all — never
|
|
||||||
* distinguishes the reason, same posture as {@link requireAuth}.
|
|
||||||
*/
|
|
||||||
export function requireInternalWorker(req: Request, _res: Response, next: NextFunction): void {
|
|
||||||
const provided = req.header(INTERNAL_WORKER_SECRET_HEADER);
|
|
||||||
if (env.INTERNAL_WORKER_SECRET === undefined || provided === undefined) {
|
|
||||||
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `timingSafeEqual` throws on mismatched buffer lengths rather than
|
|
||||||
// returning `false` — checked separately first. A length mismatch alone
|
|
||||||
// already means "not equal", so this loses no timing-attack protection
|
|
||||||
// (an attacker learns nothing beyond what a differing length itself
|
|
||||||
// already reveals, no different from `!==` on the common case where the
|
|
||||||
// secret's real length isn't a secret worth protecting).
|
|
||||||
const expected = Buffer.from(env.INTERNAL_WORKER_SECRET);
|
|
||||||
const actual = Buffer.from(provided);
|
|
||||||
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
|
||||||
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import {
|
|
||||||
auditBatchQuerySchema,
|
|
||||||
submitTrainingSuggestionsSchema,
|
|
||||||
workerBatchQuerySchema,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
|
||||||
import { requireInternalWorker } from "../../middlewares/require-internal-worker.js";
|
|
||||||
import {
|
|
||||||
getAuditBatch,
|
|
||||||
getPendingCorrections,
|
|
||||||
submitTrainingSuggestions,
|
|
||||||
} from "./tech-step-worker.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Router mounted at `/internal/tech-steps` in app.ts — every route requires
|
|
||||||
* {@link requireInternalWorker}, never {@link requireAuth}
|
|
||||||
* (`middlewares/require-auth.ts`): this is `services/tech-step-llm-worker`
|
|
||||||
* authenticating as itself, not a user session. See that middleware's own
|
|
||||||
* doc comment for why the two are deliberately separate mechanisms.
|
|
||||||
*/
|
|
||||||
export const techStepWorkerRouter = Router();
|
|
||||||
|
|
||||||
techStepWorkerRouter.get(
|
|
||||||
"/audit-batch",
|
|
||||||
requireInternalWorker,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
const input = auditBatchQuerySchema.parse(req.query);
|
|
||||||
res.status(200).json(await getAuditBatch(input.locale, input.limit));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
techStepWorkerRouter.get(
|
|
||||||
"/pending-corrections",
|
|
||||||
requireInternalWorker,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
const input = workerBatchQuerySchema.parse(req.query);
|
|
||||||
res.status(200).json(await getPendingCorrections(input.limit));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
techStepWorkerRouter.post(
|
|
||||||
"/training-suggestions",
|
|
||||||
requireInternalWorker,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
const input = submitTrainingSuggestionsSchema.parse(req.body);
|
|
||||||
res.status(201).json(await submitTrainingSuggestions(input));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import {
|
|
||||||
ErrorCode,
|
|
||||||
type PendingTechStepCorrectionView,
|
|
||||||
type SubmitTrainingSuggestionsInput,
|
|
||||||
type TechStepAuditClauseView,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import {
|
|
||||||
CONFIDENCE_THRESHOLD,
|
|
||||||
techStepClassifier,
|
|
||||||
} from "../../lib/recipe-matching/tech-step-matcher.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read/write surface `services/tech-step-llm-worker` calls through
|
|
||||||
* `/internal/tech-steps/*` (`tech-step-worker.routes.ts`, guarded by
|
|
||||||
* `requireInternalWorker`) — the worker has no Prisma client or database
|
|
||||||
* credentials of its own (see that service's own README), so every
|
|
||||||
* corrections/audit-sample read and every suggestion write goes through
|
|
||||||
* here rather than the worker touching this schema directly. Keeps
|
|
||||||
* `apps/api` the single owner of the schema/migrations, and keeps the
|
|
||||||
* worker a pure "read some text, run inference, post a suggestion" process
|
|
||||||
* with nothing to keep in sync if the schema changes shape.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many of the most recently created `Step`s {@link getAuditBatch} scans
|
|
||||||
* per call before filtering down to low-confidence clauses — a fixed
|
|
||||||
* recency-biased sample, not every `Step` in the database, to keep this
|
|
||||||
* endpoint's cost bounded regardless of how large the recipe catalog gets.
|
|
||||||
* Recently-added steps are also the steps most likely to still use
|
|
||||||
* vocabulary the training corpus hasn't caught up with yet, which is
|
|
||||||
* exactly what this audit is for. A smarter sampling strategy (e.g.
|
|
||||||
* weighted by how often a recipe is actually viewed/planned) is future
|
|
||||||
* work, not needed for this feature's first version.
|
|
||||||
*/
|
|
||||||
const AUDIT_SAMPLE_SIZE = 200;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every low-confidence clause found across a recency-biased sample of
|
|
||||||
* existing `Step`s (see {@link AUDIT_SAMPLE_SIZE}), for
|
|
||||||
* `services/tech-step-llm-worker`'s `audit-low-confidence` job to get a
|
|
||||||
* second opinion on. "Low-confidence" mirrors exactly what
|
|
||||||
* `TechStepClassifierService._classifyClause` itself distrusts (a clause
|
|
||||||
* with an NER anchor but a classifier score under
|
|
||||||
* {@link CONFIDENCE_THRESHOLD}) — the same clauses that pipeline already
|
|
||||||
* has to fall back to keyword-anchor guessing for, not an arbitrary
|
|
||||||
* separate cutoff.
|
|
||||||
*/
|
|
||||||
export async function getAuditBatch(
|
|
||||||
locale: string,
|
|
||||||
limit: number,
|
|
||||||
): Promise<TechStepAuditClauseView[]> {
|
|
||||||
try {
|
|
||||||
const steps = await prisma.step.findMany({
|
|
||||||
orderBy: { id: "desc" },
|
|
||||||
take: AUDIT_SAMPLE_SIZE,
|
|
||||||
select: { id: true, recipeId: true, description: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const results: TechStepAuditClauseView[] = [];
|
|
||||||
for (const step of steps) {
|
|
||||||
if (results.length >= limit) break;
|
|
||||||
const clauses = await techStepClassifier.classifyClauses(step.description, locale);
|
|
||||||
for (const clause of clauses) {
|
|
||||||
if (results.length >= limit) break;
|
|
||||||
const isLowConfidence = clause.anchorUid !== null && clause.score < CONFIDENCE_THRESHOLD;
|
|
||||||
if (!isLowConfidence) continue;
|
|
||||||
results.push({
|
|
||||||
stepId: step.id,
|
|
||||||
recipeId: step.recipeId,
|
|
||||||
clauseText: clause.clauseText,
|
|
||||||
anchorKey: clause.anchorUid,
|
|
||||||
intentKey: clause.intentUid,
|
|
||||||
score: clause.score,
|
|
||||||
locale,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every `StepTechStepCorrection` not yet turned into a
|
|
||||||
* `TechStepTrainingSuggestion` (`consumedAt IS NULL`), oldest first — a
|
|
||||||
* FIFO queue the worker's `transform-corrections` job drains, `limit` at a
|
|
||||||
* time.
|
|
||||||
*
|
|
||||||
* `correctedTechStepId IS NOT NULL` on top of `consumedAt IS NULL`: a
|
|
||||||
* correction that *removes* a match ("no technique belongs here",
|
|
||||||
* `correctedTechStepId: null` — see `StepTechStepCorrection`'s schema doc
|
|
||||||
* comment) has no technique to propose new positive training data *for*.
|
|
||||||
* Surfacing it here would leave it permanently unconsumable (the worker
|
|
||||||
* has nothing to submit a suggestion for, so it would never stamp
|
|
||||||
* `consumedAt`, and it would keep re-appearing in every future batch
|
|
||||||
* forever) — excluded at the source instead, not filtered/skipped
|
|
||||||
* downstream by the worker.
|
|
||||||
*/
|
|
||||||
export async function getPendingCorrections(
|
|
||||||
limit: number,
|
|
||||||
): Promise<PendingTechStepCorrectionView[]> {
|
|
||||||
try {
|
|
||||||
const corrections = await prisma.stepTechStepCorrection.findMany({
|
|
||||||
where: { consumedAt: null, correctedTechStepId: { not: null } },
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
take: limit,
|
|
||||||
include: {
|
|
||||||
step: { select: { id: true, recipeId: true, description: true } },
|
|
||||||
previousTechStep: { select: { key: true } },
|
|
||||||
correctedTechStep: { select: { key: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return corrections.map((correction) => ({
|
|
||||||
id: correction.id,
|
|
||||||
stepId: correction.step.id,
|
|
||||||
recipeId: correction.step.recipeId,
|
|
||||||
clauseText: correction.step.description.slice(correction.start, correction.end),
|
|
||||||
start: correction.start,
|
|
||||||
end: correction.end,
|
|
||||||
previousTechStepKey: correction.previousTechStep?.key ?? null,
|
|
||||||
correctedTechStepKey: correction.correctedTechStep?.key ?? null,
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persists a batch of `TechStepTrainingSuggestion`s and, for every
|
|
||||||
* suggestion sourced from a correction, stamps that correction's
|
|
||||||
* `consumedAt` in the same transaction — so a worker run that crashes
|
|
||||||
* partway through never leaves a correction consumed with no matching
|
|
||||||
* suggestion, or a suggestion created against a correction still (wrongly)
|
|
||||||
* eligible to be picked up again by the next run.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `404 TECH_STEP_NOT_FOUND` if any `techStepKey` in the
|
|
||||||
* batch doesn't match a reference `TechStep` — rejects the *whole* batch
|
|
||||||
* rather than skipping the bad entries, on the theory that a worker
|
|
||||||
* sending an unknown key is more likely a version-skew bug (its own
|
|
||||||
* taxonomy copy, `services/tech-step-llm-worker/src/tech-step-taxonomy.ts`,
|
|
||||||
* drifting from this API's `TechStep` catalog) than a one-off it should
|
|
||||||
* silently tolerate.
|
|
||||||
*/
|
|
||||||
export async function submitTrainingSuggestions(
|
|
||||||
input: SubmitTrainingSuggestionsInput,
|
|
||||||
): Promise<{ created: number }> {
|
|
||||||
try {
|
|
||||||
const techStepKeys = [
|
|
||||||
...new Set(input.suggestions.map((suggestion) => suggestion.techStepKey)),
|
|
||||||
];
|
|
||||||
const techSteps = await prisma.techStep.findMany({
|
|
||||||
where: { key: { in: techStepKeys } },
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
const techStepIdByKey = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
|
|
||||||
const missingKeys = techStepKeys.filter((key) => !techStepIdByKey.has(key));
|
|
||||||
if (missingKeys.length > 0) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.TECH_STEP_NOT_FOUND,
|
|
||||||
`Unknown techStepKey(s): ${missingKeys.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
for (const suggestion of input.suggestions) {
|
|
||||||
// Non-null by construction — every key in `input.suggestions` was
|
|
||||||
// just confirmed present in `techStepIdByKey` above (the `missingKeys`
|
|
||||||
// check would have thrown otherwise).
|
|
||||||
const techStepId = techStepIdByKey.get(suggestion.techStepKey);
|
|
||||||
if (techStepId === undefined) continue;
|
|
||||||
|
|
||||||
await tx.techStepTrainingSuggestion.create({
|
|
||||||
data: {
|
|
||||||
techStepId,
|
|
||||||
locale: suggestion.locale,
|
|
||||||
suggestedSynonyms: suggestion.suggestedSynonyms,
|
|
||||||
suggestedUtterances: suggestion.suggestedUtterances,
|
|
||||||
sourceType: suggestion.sourceType,
|
|
||||||
sourceCorrectionId: suggestion.sourceCorrectionId ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (suggestion.sourceCorrectionId !== null && suggestion.sourceCorrectionId !== undefined) {
|
|
||||||
await tx.stepTechStepCorrection.update({
|
|
||||||
where: { id: suggestion.sourceCorrectionId },
|
|
||||||
data: { consumedAt: new Date() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { created: input.suggestions.length };
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,506 +0,0 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import {
|
|
||||||
ErrorCode,
|
|
||||||
type StepTechStepCorrectionView,
|
|
||||||
type SubmitTechStepCorrectionInput,
|
|
||||||
type SubmitTechStepCorrectionResult,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import { assertRecipeVisible, toStepTechStepViews } from "./recipe.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User-submitted corrections to a step's detected techniques
|
|
||||||
* (`StepTechStepCorrection` in schema.prisma) — kept in its own module
|
|
||||||
* rather than folded into `recipe.service.ts`, same "one file per concern"
|
|
||||||
* split that file itself follows for `tech-step-matcher.ts`. Deliberately
|
|
||||||
* open to *any* viewer who can see the recipe, not just its author (unlike
|
|
||||||
* every write path in `recipe.service.ts`, which uses `assertIsAuthor`) —
|
|
||||||
* correcting a mislabeled technique isn't editing the recipe's own
|
|
||||||
* content, and restricting it to authors would starve the training-data
|
|
||||||
* feedback loop (`services/tech-step-llm-worker`) of the volume it needs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
type CorrectionWithTechSteps = Prisma.StepTechStepCorrectionGetPayload<{
|
|
||||||
include: { previousTechStep: true; correctedTechStep: true };
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const correctionInclude = {
|
|
||||||
previousTechStep: true,
|
|
||||||
correctedTechStep: true,
|
|
||||||
} satisfies Prisma.StepTechStepCorrectionInclude;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads `stepId`'s current `description` length (the only thing a
|
|
||||||
* correction needs from the step itself), or throws — `404 STEP_NOT_FOUND`
|
|
||||||
* if no such step exists, or if it exists but doesn't belong to `recipeId`
|
|
||||||
* (the route's own `:id`/`:stepId` nesting is meaningless otherwise — a
|
|
||||||
* request naming a real step under the wrong recipe should look identical
|
|
||||||
* to naming one that doesn't exist, same "don't leak which part was wrong"
|
|
||||||
* posture `assertRecipeVisible` already has for visibility). Otherwise
|
|
||||||
* whatever {@link assertRecipeVisible} throws (`404 RECIPE_NOT_FOUND`,
|
|
||||||
* never `403`) if the recipe exists but isn't visible to the viewer.
|
|
||||||
*/
|
|
||||||
async function loadVisibleStepOrThrow(
|
|
||||||
recipeId: number,
|
|
||||||
stepId: number,
|
|
||||||
viewerId: number,
|
|
||||||
viewerHouseId: number | null,
|
|
||||||
): Promise<{ id: number; descriptionLength: number }> {
|
|
||||||
try {
|
|
||||||
const step = await prisma.step.findUnique({
|
|
||||||
where: { id: stepId },
|
|
||||||
select: { id: true, recipeId: true, description: true },
|
|
||||||
});
|
|
||||||
if (!step || step.recipeId !== recipeId) {
|
|
||||||
throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`);
|
|
||||||
}
|
|
||||||
await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId);
|
|
||||||
return { id: step.id, descriptionLength: step.description.length };
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Throws `404 TECH_STEP_NOT_FOUND` if any id in `ids` doesn't match a reference `TechStep` row — same shape as `recipe.service.ts`'s `assertIngredientsExist`/`assertUnitsExist` for the recipe payload's own reference ids. */
|
|
||||||
async function assertTechStepsExist(ids: number[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (ids.length === 0) return;
|
|
||||||
const found = await prisma.techStep.findMany({
|
|
||||||
where: { id: { in: ids } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const foundIds = new Set(found.map((techStep) => techStep.id));
|
|
||||||
const missing = ids.filter((id) => !foundIds.has(id));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.TECH_STEP_NOT_FOUND,
|
|
||||||
`TechStep ids not found: ${missing.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Throws `404 INGREDIENT_NOT_FOUND` if any id in `ids` doesn't match a reference `Ingredient` row — same shape as {@link assertTechStepsExist}, checking `input.ingredients[].ingredientId` instead. */
|
|
||||||
async function assertIngredientsExist(ids: number[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
const uniqueIds = [...new Set(ids)];
|
|
||||||
if (uniqueIds.length === 0) return;
|
|
||||||
const found = await prisma.ingredient.findMany({
|
|
||||||
where: { id: { in: uniqueIds } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
|
||||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.INGREDIENT_NOT_FOUND,
|
|
||||||
`Ingredient ids not found: ${missing.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Throws `404 UNIT_NOT_FOUND` if any id in `ids` doesn't match a reference `Unit` row — same shape as {@link assertIngredientsExist}, checking `input.ingredients[].unitId` instead. */
|
|
||||||
async function assertUnitsExist(ids: number[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
const uniqueIds = [...new Set(ids)];
|
|
||||||
if (uniqueIds.length === 0) return;
|
|
||||||
const found = await prisma.unit.findMany({
|
|
||||||
where: { id: { in: uniqueIds } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const foundIds = new Set(found.map((unit) => unit.id));
|
|
||||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.UNIT_NOT_FOUND,
|
|
||||||
`Unit ids not found: ${missing.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Throws `404 UTENSIL_NOT_FOUND` if any id in `ids` doesn't match a reference `Utensil` row — same shape as {@link assertIngredientsExist}, checking `input.utensils[].utensilId` instead. */
|
|
||||||
async function assertUtensilsExist(ids: number[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
const uniqueIds = [...new Set(ids)];
|
|
||||||
if (uniqueIds.length === 0) return;
|
|
||||||
const found = await prisma.utensil.findMany({
|
|
||||||
where: { id: { in: uniqueIds } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const foundIds = new Set(found.map((utensil) => utensil.id));
|
|
||||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.UTENSIL_NOT_FOUND,
|
|
||||||
`Utensil ids not found: ${missing.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renumbers every one of `stepId`'s `StepTechStep` rows' `order` by
|
|
||||||
* ascending `start` (nulls-still-possible legacy rows, see that model's
|
|
||||||
* schema doc comment, sort last) — the dense, reading-order 0-based
|
|
||||||
* sequence `@@id([stepId, order])` requires, regardless of whether a
|
|
||||||
* caller just inserted, updated, or deleted a row. Simpler and less
|
|
||||||
* error-prone than shifting only the affected neighbors' `order` by hand.
|
|
||||||
*
|
|
||||||
* Two passes, through a disjoint negative range first: updating straight
|
|
||||||
* into the final 0..N-1 positions in one pass risks a transient
|
|
||||||
* `(stepId, order)` collision (e.g. the row destined for `order: 0` isn't
|
|
||||||
* necessarily the one already sitting there) — `order` is always `>= 0`
|
|
||||||
* in real usage, so a negative range can never collide with a live row.
|
|
||||||
*
|
|
||||||
* Exported for `scripts/backfill-tech-steps.ts` to reuse after it
|
|
||||||
* recomputes just the `"auto"` subset of a step's rows, so the combined
|
|
||||||
* `"auto"` + `"manual"` sequence still ends up in one coherent
|
|
||||||
* reading-order.
|
|
||||||
*/
|
|
||||||
export async function renumberStepTechSteps(
|
|
||||||
tx: Prisma.TransactionClient,
|
|
||||||
stepId: number,
|
|
||||||
): Promise<void> {
|
|
||||||
const rows = await tx.stepTechStep.findMany({ where: { stepId } });
|
|
||||||
const sorted = [...rows].sort(
|
|
||||||
(a, b) => (a.start ?? Number.POSITIVE_INFINITY) - (b.start ?? Number.POSITIVE_INFINITY),
|
|
||||||
);
|
|
||||||
for (const [index, row] of sorted.entries()) {
|
|
||||||
await tx.stepTechStep.update({
|
|
||||||
where: { stepId_order: { stepId, order: row.order } },
|
|
||||||
data: { order: -(index + 1) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const [index] of sorted.entries()) {
|
|
||||||
await tx.stepTechStep.update({
|
|
||||||
where: { stepId_order: { stepId, order: -(index + 1) } },
|
|
||||||
data: { order: index },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One ingredient/utensil mention the viewer themselves selected, ready to persist — see {@link applyManualCorrection}'s own doc comment for the "manual replaces all" semantics these are written under. */
|
|
||||||
interface ManualIngredientMention {
|
|
||||||
ingredientId: number;
|
|
||||||
quantity: number | null;
|
|
||||||
unitId: number | null;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
interface ManualUtensilMention {
|
|
||||||
utensilId: number;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies a correction's *effect* on `stepId`'s real `StepTechStep`
|
|
||||||
* sequence, immediately — not just recorded as a pending suggestion for
|
|
||||||
* `services/tech-step-llm-worker` to eventually process (see
|
|
||||||
* `StepTechStepCorrection`'s schema doc comment; this is *in addition to*
|
|
||||||
* that offline feedback loop, not instead of it). `previousTechStepId`/
|
|
||||||
* `correctedTechStepId` mean exactly what they do on
|
|
||||||
* `StepTechStepCorrection` itself (`SubmitTechStepCorrectionInput`'s doc
|
|
||||||
* comment, `packages/shared`):
|
|
||||||
*
|
|
||||||
* - `correctedTechStepId` set (add or relabel): a `"manual"` row is
|
|
||||||
* written at the correction's own `[start, end)` — updating the
|
|
||||||
* existing entry in place when one matching `previousTechStepId`
|
|
||||||
* overlaps this span, otherwise inserting a new one. No `contextStart`/
|
|
||||||
* `contextEnd` — a correction only ever carries the tight span the user
|
|
||||||
* themselves selected/clicked, nothing wider to highlight around it.
|
|
||||||
* - `previousTechStepId` alone (remove, `correctedTechStepId: null`): the
|
|
||||||
* matching existing entry is deleted outright (cascading away any
|
|
||||||
* ingredient/utensil metadata attached to it, auto or manual — nothing
|
|
||||||
* left to attach metadata to once the technique itself is gone). A
|
|
||||||
* no-op if none matches (nothing to remove).
|
|
||||||
*
|
|
||||||
* `metadata`, when given (only ever alongside a real `correctedTechStepId`
|
|
||||||
* — enforced by `submitTechStepCorrectionSchema`, not re-checked here),
|
|
||||||
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil` row on
|
|
||||||
* this occurrence — `source: "auto"` (the classifier's own detection) and
|
|
||||||
* any earlier `"manual"` set alike — with the newly-submitted one. This is
|
|
||||||
* "le manuel remplace tout" (confirmed with the user): the resolved
|
|
||||||
* `order` this technique ends up at (whichever branch above produced it)
|
|
||||||
* is the same `techStepOrder` both metadata tables key on, so the same
|
|
||||||
* `deleteMany` + `createMany` pair below is correct whether this call just
|
|
||||||
* updated an existing row (which may already carry auto-detected
|
|
||||||
* metadata) or created a brand new one (nothing to delete yet — a no-op
|
|
||||||
* `deleteMany`, not a special case).
|
|
||||||
*
|
|
||||||
* Runs inside the same transaction {@link submitTechStepCorrection} uses
|
|
||||||
* for the audit-trail insert, so a request never leaves any of these
|
|
||||||
* effects (the permanent correction record, the live sequence change, the
|
|
||||||
* metadata replacement) only partially applied.
|
|
||||||
*/
|
|
||||||
async function applyManualCorrection(
|
|
||||||
tx: Prisma.TransactionClient,
|
|
||||||
stepId: number,
|
|
||||||
span: { start: number; end: number },
|
|
||||||
previousTechStepId: number | null,
|
|
||||||
correctedTechStepId: number | null,
|
|
||||||
metadata?: { ingredients: ManualIngredientMention[]; utensils: ManualUtensilMention[] },
|
|
||||||
): Promise<void> {
|
|
||||||
const existing = await tx.stepTechStep.findMany({ where: { stepId } });
|
|
||||||
|
|
||||||
const target =
|
|
||||||
previousTechStepId !== null
|
|
||||||
? existing.find(
|
|
||||||
(row) =>
|
|
||||||
row.techStepId === previousTechStepId &&
|
|
||||||
row.start !== null &&
|
|
||||||
row.end !== null &&
|
|
||||||
row.start < span.end &&
|
|
||||||
span.start < row.end,
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (correctedTechStepId !== null) {
|
|
||||||
const order = target
|
|
||||||
? target.order
|
|
||||||
: existing.reduce((max, row) => Math.max(max, row.order), -1) + 1;
|
|
||||||
if (target) {
|
|
||||||
await tx.stepTechStep.update({
|
|
||||||
where: { stepId_order: { stepId, order } },
|
|
||||||
data: {
|
|
||||||
techStepId: correctedTechStepId,
|
|
||||||
start: span.start,
|
|
||||||
end: span.end,
|
|
||||||
contextStart: null,
|
|
||||||
contextEnd: null,
|
|
||||||
source: "manual",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await tx.stepTechStep.create({
|
|
||||||
data: {
|
|
||||||
stepId,
|
|
||||||
techStepId: correctedTechStepId,
|
|
||||||
order,
|
|
||||||
start: span.start,
|
|
||||||
end: span.end,
|
|
||||||
source: "manual",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata !== undefined) {
|
|
||||||
await tx.stepTechStepIngredient.deleteMany({ where: { stepId, techStepOrder: order } });
|
|
||||||
await tx.stepTechStepUtensil.deleteMany({ where: { stepId, techStepOrder: order } });
|
|
||||||
if (metadata.ingredients.length > 0) {
|
|
||||||
await tx.stepTechStepIngredient.createMany({
|
|
||||||
data: metadata.ingredients.map((ingredient) => ({
|
|
||||||
stepId,
|
|
||||||
techStepOrder: order,
|
|
||||||
ingredientId: ingredient.ingredientId,
|
|
||||||
quantity: ingredient.quantity,
|
|
||||||
unitId: ingredient.unitId,
|
|
||||||
start: ingredient.start,
|
|
||||||
end: ingredient.end,
|
|
||||||
source: "manual",
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (metadata.utensils.length > 0) {
|
|
||||||
await tx.stepTechStepUtensil.createMany({
|
|
||||||
data: metadata.utensils.map((utensil) => ({
|
|
||||||
stepId,
|
|
||||||
techStepOrder: order,
|
|
||||||
utensilId: utensil.utensilId,
|
|
||||||
start: utensil.start,
|
|
||||||
end: utensil.end,
|
|
||||||
source: "manual",
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (target) {
|
|
||||||
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
|
|
||||||
}
|
|
||||||
|
|
||||||
await renumberStepTechSteps(tx, stepId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorrectionView {
|
|
||||||
return {
|
|
||||||
id: correction.id,
|
|
||||||
start: correction.start,
|
|
||||||
end: correction.end,
|
|
||||||
previousTechStep: correction.previousTechStep
|
|
||||||
? { id: correction.previousTechStep.id, key: correction.previousTechStep.key }
|
|
||||||
: null,
|
|
||||||
correctedTechStep: correction.correctedTechStep
|
|
||||||
? { id: correction.correctedTechStep.id, key: correction.correctedTechStep.key }
|
|
||||||
: null,
|
|
||||||
createdAt: correction.createdAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Records one correction to `stepId`'s detected techniques, submitted by
|
|
||||||
* `correctorId`, and immediately applies its effect to the step's real
|
|
||||||
* `StepTechStep` sequence (a `"manual"`-tagged row — see
|
|
||||||
* {@link applyManualCorrection}) — see
|
|
||||||
* {@link SubmitTechStepCorrectionInput}'s doc comment (`packages/shared`)
|
|
||||||
* for what `previousTechStepId`/`correctedTechStepId` each mean. The audit
|
|
||||||
* record itself is never edited/deleted afterward (see
|
|
||||||
* `StepTechStepCorrection`'s schema doc comment) — only the live sequence
|
|
||||||
* changes on a later correction to the same span.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see
|
|
||||||
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
|
|
||||||
* `start`/`end` (the correction's own span, or any of
|
|
||||||
* `input.ingredients`/`input.utensils`' own spans) fall outside the
|
|
||||||
* step's current `description` (it may have been edited since the user
|
|
||||||
* last saw it). `404 TECH_STEP_NOT_FOUND`/`404 INGREDIENT_NOT_FOUND`/
|
|
||||||
* `404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND` if any referenced id
|
|
||||||
* doesn't exist.
|
|
||||||
*/
|
|
||||||
export async function submitTechStepCorrection(
|
|
||||||
recipeId: number,
|
|
||||||
stepId: number,
|
|
||||||
input: SubmitTechStepCorrectionInput,
|
|
||||||
correctorId: number,
|
|
||||||
viewerHouseId: number | null,
|
|
||||||
): Promise<SubmitTechStepCorrectionResult> {
|
|
||||||
try {
|
|
||||||
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
|
|
||||||
|
|
||||||
const spans = [
|
|
||||||
{ start: input.start, end: input.end },
|
|
||||||
...(input.ingredients ?? []),
|
|
||||||
...(input.utensils ?? []),
|
|
||||||
];
|
|
||||||
for (const span of spans) {
|
|
||||||
if (span.start >= step.descriptionLength || span.end > step.descriptionLength) {
|
|
||||||
throw new HttpError(
|
|
||||||
400,
|
|
||||||
ErrorCode.INVALID_CORRECTION_SPAN,
|
|
||||||
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const techStepIds = [input.previousTechStepId, input.correctedTechStepId].filter(
|
|
||||||
(id): id is number => id !== null && id !== undefined,
|
|
||||||
);
|
|
||||||
await assertTechStepsExist(techStepIds);
|
|
||||||
await assertIngredientsExist((input.ingredients ?? []).map((i) => i.ingredientId));
|
|
||||||
await assertUnitsExist(
|
|
||||||
(input.ingredients ?? []).flatMap((i) =>
|
|
||||||
i.unitId !== null && i.unitId !== undefined ? [i.unitId] : [],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
|
|
||||||
|
|
||||||
const { correction, techSteps } = await prisma.$transaction(async (tx) => {
|
|
||||||
const createdCorrection = await tx.stepTechStepCorrection.create({
|
|
||||||
data: {
|
|
||||||
stepId: step.id,
|
|
||||||
correctorId,
|
|
||||||
start: input.start,
|
|
||||||
end: input.end,
|
|
||||||
previousTechStepId: input.previousTechStepId ?? null,
|
|
||||||
correctedTechStepId: input.correctedTechStepId ?? null,
|
|
||||||
},
|
|
||||||
include: correctionInclude,
|
|
||||||
});
|
|
||||||
|
|
||||||
await applyManualCorrection(
|
|
||||||
tx,
|
|
||||||
step.id,
|
|
||||||
{ start: input.start, end: input.end },
|
|
||||||
input.previousTechStepId ?? null,
|
|
||||||
input.correctedTechStepId ?? null,
|
|
||||||
input.ingredients === undefined && input.utensils === undefined
|
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
ingredients: (input.ingredients ?? []).map((ingredient) => ({
|
|
||||||
ingredientId: ingredient.ingredientId,
|
|
||||||
quantity: ingredient.quantity ?? null,
|
|
||||||
unitId: ingredient.unitId ?? null,
|
|
||||||
start: ingredient.start,
|
|
||||||
end: ingredient.end,
|
|
||||||
})),
|
|
||||||
utensils: (input.utensils ?? []).map((utensil) => ({
|
|
||||||
utensilId: utensil.utensilId,
|
|
||||||
start: utensil.start,
|
|
||||||
end: utensil.end,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Same nested `ingredients`/`utensils` include as `recipe.service.ts`'s
|
|
||||||
// `recipeInclude` — `toStepTechStepViews` (reused below) expects it,
|
|
||||||
// so the fresh sequence read right after a manual correction resolves
|
|
||||||
// exactly the same way a normal `GET /recipes/:id` would.
|
|
||||||
const freshTechSteps = await tx.stepTechStep.findMany({
|
|
||||||
where: { stepId: step.id },
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
include: {
|
|
||||||
techStep: true,
|
|
||||||
ingredients: {
|
|
||||||
include: {
|
|
||||||
ingredient: {
|
|
||||||
include: {
|
|
||||||
allergies: { include: { allergy: { include: { category: true } } } },
|
|
||||||
diets: { include: { diet: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
unit: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
utensils: { include: { utensil: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { correction: createdCorrection, techSteps: freshTechSteps };
|
|
||||||
});
|
|
||||||
|
|
||||||
return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every correction submitted so far for `stepId`, most recent first —
|
|
||||||
* mainly useful for a user checking what's already been submitted (by
|
|
||||||
* anyone) for a span before adding another (see `StepTechStepCorrectionView`'s
|
|
||||||
* doc comment, `packages/shared`).
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see {@link loadVisibleStepOrThrow}.
|
|
||||||
*/
|
|
||||||
export async function listTechStepCorrections(
|
|
||||||
recipeId: number,
|
|
||||||
stepId: number,
|
|
||||||
viewerId: number,
|
|
||||||
viewerHouseId: number | null,
|
|
||||||
): Promise<StepTechStepCorrectionView[]> {
|
|
||||||
try {
|
|
||||||
const step = await loadVisibleStepOrThrow(recipeId, stepId, viewerId, viewerHouseId);
|
|
||||||
const corrections = await prisma.stepTechStepCorrection.findMany({
|
|
||||||
where: { stepId: step.id },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
include: correctionInclude,
|
|
||||||
});
|
|
||||||
return corrections.map(toCorrectionView);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,6 @@ 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";
|
||||||
|
|
@ -18,10 +17,6 @@ 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();
|
||||||
|
|
@ -35,15 +30,6 @@ 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,
|
||||||
|
|
@ -123,29 +109,3 @@ 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,29 +45,7 @@ function recipeInclude(viewerId: number) {
|
||||||
steps: {
|
steps: {
|
||||||
orderBy: { order: "asc" },
|
orderBy: { order: "asc" },
|
||||||
include: {
|
include: {
|
||||||
techSteps: {
|
techSteps: { orderBy: { order: "asc" }, include: { techStep: true } },
|
||||||
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 } },
|
||||||
|
|
@ -78,13 +56,11 @@ function recipeInclude(viewerId: number) {
|
||||||
type RecipeWithDetails = Prisma.RecipeGetPayload<{
|
type RecipeWithDetails = Prisma.RecipeGetPayload<{
|
||||||
include: ReturnType<typeof recipeInclude>;
|
include: ReturnType<typeof recipeInclude>;
|
||||||
}>;
|
}>;
|
||||||
/** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */
|
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
||||||
export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
|
||||||
/** 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. 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). */
|
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. */
|
||||||
export function toUnitView(unit: UnitWithDetails): UnitView {
|
function toUnitView(unit: UnitWithDetails): UnitView {
|
||||||
return {
|
return {
|
||||||
id: unit.id,
|
id: unit.id,
|
||||||
key: unit.key,
|
key: unit.key,
|
||||||
|
|
@ -93,8 +69,8 @@ export function toUnitView(unit: UnitWithDetails): UnitView {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same flattening as `reference.service.ts`'s `getIngredients`. Exported — see {@link toUnitView}'s doc comment, same reuse by `shopping-list.service.ts`. */
|
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */
|
||||||
export function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
|
function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
|
||||||
return {
|
return {
|
||||||
id: ingredient.id,
|
id: ingredient.id,
|
||||||
key: ingredient.key,
|
key: ingredient.key,
|
||||||
|
|
@ -156,48 +132,19 @@ 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.
|
|
||||||
*/
|
*/
|
||||||
export function toStepTechStepViews(
|
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, source, ingredients, utensils } =
|
const { start, end, contextStart, contextEnd, techStep } = stepTechStep;
|
||||||
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;
|
||||||
|
|
@ -592,22 +539,6 @@ 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,7 +7,6 @@ import {
|
||||||
getSources,
|
getSources,
|
||||||
getTechSteps,
|
getTechSteps,
|
||||||
getUnits,
|
getUnits,
|
||||||
getUtensils,
|
|
||||||
} from "./reference.service.js";
|
} from "./reference.service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -56,13 +55,6 @@ referenceRouter.get(
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
referenceRouter.get(
|
|
||||||
"/utensils",
|
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
|
||||||
res.status(200).json(await getUtensils());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
referenceRouter.get(
|
referenceRouter.get(
|
||||||
"/sources",
|
"/sources",
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
wrapAsyncHandler(async (_req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ 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";
|
||||||
|
|
||||||
|
|
@ -86,19 +85,6 @@ export async function getTechSteps(): Promise<TechStepView[]> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* All reference cooking utensils, ordered by key (see {@link getDiets} for
|
|
||||||
* why) — small, static list (see `reference-seed-data.ts`'s `UTENSILS`),
|
|
||||||
* same bare `id`/`key` shape as {@link getTechSteps}.
|
|
||||||
*/
|
|
||||||
export async function getUtensils(): Promise<UtensilView[]> {
|
|
||||||
try {
|
|
||||||
return await prisma.utensil.findMany({ orderBy: { key: "asc" } });
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getDiets()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every implemented recipe source, ordered by name (not `key` — unlike
|
* Every 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
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import { parseDateOnly } from "@batch-cooking/date-tools";
|
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import { ErrorCode, getShoppingListSchema } from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
|
||||||
import { getShoppingListForDate } from "./shopping-list.service.js";
|
|
||||||
|
|
||||||
/** Router mounted at `/shopping-list` in app.ts. */
|
|
||||||
export const shoppingListRouter = Router();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the authenticated user's household's shopping list for the week
|
|
||||||
* covering `?date=` (`YYYY-MM-DD`) — every ingredient line of every recipe
|
|
||||||
* planned that week, summed (see {@link getShoppingListForDate}). Always
|
|
||||||
* `200`, never `null` — no household or nothing planned that week both
|
|
||||||
* come back as a normal `ShoppingListView` with an empty `items` array.
|
|
||||||
*/
|
|
||||||
shoppingListRouter.get(
|
|
||||||
"/",
|
|
||||||
requireAuth,
|
|
||||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
|
||||||
const input = getShoppingListSchema.parse(req.query);
|
|
||||||
const date = parseDateOnly(input.date);
|
|
||||||
if (date === null) {
|
|
||||||
throw new HttpError(
|
|
||||||
400,
|
|
||||||
ErrorCode.VALIDATION_ERROR,
|
|
||||||
`Not a real calendar date: ${input.date}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const shoppingList = await getShoppingListForDate(res.locals.userProfile.houseId, date);
|
|
||||||
res.status(200).json(shoppingList);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
|
|
||||||
import type {
|
|
||||||
IngredientView,
|
|
||||||
ShoppingListItemView,
|
|
||||||
ShoppingListView,
|
|
||||||
UnitView,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
|
|
||||||
|
|
||||||
/** Prisma `include` for a `Planning` query that needs, for every item, just enough of its recipe to compute a shopping list — `portions` (to scale `RecipeIngredient.quantity`) and the ingredient lines themselves, each resolved the same way `recipe.service.ts`'s own `recipeInclude` resolves them (so {@link toIngredientView}/{@link toUnitView} can be reused as-is). Deliberately narrower than a full `RecipeView` fetch — steps/diets/favorites are never read here. */
|
|
||||||
function shoppingListPlanningInclude() {
|
|
||||||
return {
|
|
||||||
items: {
|
|
||||||
include: {
|
|
||||||
recipe: {
|
|
||||||
select: {
|
|
||||||
portions: true,
|
|
||||||
ingredients: {
|
|
||||||
include: {
|
|
||||||
ingredient: {
|
|
||||||
include: {
|
|
||||||
allergies: { include: { allergy: { include: { category: true } } } },
|
|
||||||
diets: { include: { diet: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
unit: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies Prisma.PlanningInclude;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PlanningWithIngredients = Prisma.PlanningGetPayload<{
|
|
||||||
include: ReturnType<typeof shoppingListPlanningInclude>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. */
|
|
||||||
interface RunningTotal {
|
|
||||||
ingredient: IngredientView;
|
|
||||||
unit: UnitView;
|
|
||||||
quantity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sums every ingredient line across `items`, each scaled by that planning
|
|
||||||
* item's own portion count relative to its recipe's as-written yield
|
|
||||||
* (`RecipeIngredient.quantity × PlanningItem.portions / Recipe.portions`,
|
|
||||||
* see `PlanningItem.portions`'s doc comment in schema.prisma for why the
|
|
||||||
* two can differ). Grouped by `(ingredientId, unitId)` — **not** just
|
|
||||||
* `ingredientId` — since summing across units isn't implemented yet (see
|
|
||||||
* `ShoppingListItemView`'s doc comment): the same ingredient requested in
|
|
||||||
* two different units stays two separate lines rather than silently
|
|
||||||
* guessing a conversion. Pure/synchronous, factored out from
|
|
||||||
* {@link getShoppingListForDate} so the aggregation itself is testable
|
|
||||||
* without a database round-trip.
|
|
||||||
*/
|
|
||||||
function aggregateShoppingList(items: PlanningWithIngredients["items"]): ShoppingListItemView[] {
|
|
||||||
const totals = new Map<string, RunningTotal>();
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const scale = item.portions / item.recipe.portions;
|
|
||||||
for (const recipeIngredient of item.recipe.ingredients) {
|
|
||||||
const key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`;
|
|
||||||
const addedQuantity = Number(recipeIngredient.quantity) * scale;
|
|
||||||
|
|
||||||
const existing = totals.get(key);
|
|
||||||
if (existing) {
|
|
||||||
existing.quantity += addedQuantity;
|
|
||||||
} else {
|
|
||||||
totals.set(key, {
|
|
||||||
ingredient: toIngredientView(recipeIngredient.ingredient),
|
|
||||||
unit: toUnitView(recipeIngredient.unit),
|
|
||||||
quantity: addedQuantity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deterministic order (by the ingredient's stable `key`, not its id —
|
|
||||||
// insertion order would otherwise depend on which recipe happened to be
|
|
||||||
// read first) — the frontend re-sorts by translated label/aisle for
|
|
||||||
// display, this is just so two identical plannings always produce the
|
|
||||||
// same JSON.
|
|
||||||
return [...totals.values()].sort((a, b) => a.ingredient.key.localeCompare(b.ingredient.key));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the household's shopping list for the week covering `date` —
|
|
||||||
* every ingredient line of every recipe planned that week, aggregated (see
|
|
||||||
* {@link aggregateShoppingList}). `date` is whatever the caller wants "that
|
|
||||||
* week" to mean, same convention as `planning.service.ts`'s
|
|
||||||
* `getPlanningForDate` (a caller-parsed `?date=`, not necessarily a
|
|
||||||
* Monday).
|
|
||||||
*
|
|
||||||
* Unlike `getPlanningForDate`, this **never** returns `null` — no household
|
|
||||||
* and "no planning covers this week yet" both degrade to an empty `items`
|
|
||||||
* array on an otherwise normal `ShoppingListView` (the week's date range is
|
|
||||||
* always computable from `date` alone, even with nothing planned in it),
|
|
||||||
* rather than a separate "nothing to show" state the frontend would have to
|
|
||||||
* branch on.
|
|
||||||
*/
|
|
||||||
export async function getShoppingListForDate(
|
|
||||||
houseId: number | null,
|
|
||||||
date: DateTime,
|
|
||||||
): Promise<ShoppingListView> {
|
|
||||||
try {
|
|
||||||
const weekStart = getWeekStart(toDateOnly(date));
|
|
||||||
const weekFinish = weekStart.plus({ days: 6 });
|
|
||||||
const emptyList: ShoppingListView = {
|
|
||||||
startDate: weekStart.toJSDate().toISOString(),
|
|
||||||
finishDate: weekFinish.toJSDate().toISOString(),
|
|
||||||
items: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (houseId === null) {
|
|
||||||
return emptyList;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same "covering range" lookup as getPlanningForDate — see that
|
|
||||||
// function's doc comment for why this compares against a UTC-midnight
|
|
||||||
// JS Date rather than `weekStart`/`weekFinish` directly.
|
|
||||||
const dateOnly = toDateOnly(date).toJSDate();
|
|
||||||
const planning = await prisma.planning.findFirst({
|
|
||||||
where: {
|
|
||||||
houseId,
|
|
||||||
startDate: { lte: dateOnly },
|
|
||||||
finishDate: { gte: dateOnly },
|
|
||||||
},
|
|
||||||
orderBy: { startDate: "desc" },
|
|
||||||
include: shoppingListPlanningInclude(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!planning) {
|
|
||||||
return emptyList;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
startDate: planning.startDate.toISOString(),
|
|
||||||
finishDate: planning.finishDate.toISOString(),
|
|
||||||
items: aggregateShoppingList(planning.items),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
|
||||||
// already logs it, see `error-logger.ts`) is what actually handles it,
|
|
||||||
// this service layer just isn't allowed a bare `await` per the repo's
|
|
||||||
// async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -11,8 +11,10 @@ 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,
|
||||||
|
|
@ -27,7 +29,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, getUtensils } from "../reference/reference.service.js";
|
import { getIngredients, getUnits } from "../reference/reference.service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browsing, previewing, and importing a household's *enabled* external
|
* Browsing, previewing, and importing a household's *enabled* external
|
||||||
|
|
@ -139,13 +141,10 @@ export async function browseSource(
|
||||||
* techniques with their exact matched span (`matchTechStepSpans`, the same
|
* 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 has data for `"en"`/`"fr"` today (see `ingredient-matcher.ts`);
|
* matching itself only has English data today (see `ingredient-matcher.ts`);
|
||||||
* `loadIngredientCatalog`/`loadUnitCatalog` are always called with
|
* a non-English-locale source simply gets `ingredient`/`unit: null` on
|
||||||
* `adapter.locale` directly, never specially skipped for a particular
|
* every line, the same graceful "no matching-language data" degradation
|
||||||
* one — a source whose locale has no label table of its own just gets back
|
* `translateRecipe` already has.
|
||||||
* 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.
|
||||||
|
|
@ -178,8 +177,10 @@ export async function previewSourceItem(
|
||||||
matches: await techStepClassifier.matchTechStepSpans(step.description, adapter.locale),
|
matches: await techStepClassifier.matchTechStepSpans(step.description, adapter.locale),
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
loadIngredientCatalog(adapter.locale),
|
adapter.locale === "en"
|
||||||
loadUnitCatalog(adapter.locale),
|
? loadIngredientCatalog()
|
||||||
|
: 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]));
|
||||||
|
|
@ -188,16 +189,10 @@ export async function previewSourceItem(
|
||||||
parsed.ingredients,
|
parsed.ingredients,
|
||||||
ingredientCatalog,
|
ingredientCatalog,
|
||||||
unitCatalog,
|
unitCatalog,
|
||||||
adapter.locale,
|
|
||||||
);
|
);
|
||||||
const [ingredientViews, unitViews, utensilViews] = await Promise.all([
|
const [ingredientViews, unitViews] = await Promise.all([getIngredients(), getUnits()]);
|
||||||
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
|
||||||
|
|
@ -230,36 +225,6 @@ 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,123 +1,60 @@
|
||||||
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";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recomputes every existing `Step`'s `"auto"`-sourced `StepTechStep`
|
* One-off maintenance script: recomputes every existing `Step`'s
|
||||||
* entries against the *current* classifier
|
* `StepTechStep` sequence against the *current* classifier
|
||||||
* (`tech-step-matcher.ts`, delegating to `services/tech-step-intent-service`),
|
* (`tech-step-matcher.ts`/`tech-step-training-data.ts`), the same way
|
||||||
* the same way `updateRecipe` does when a user resaves a recipe through the UI —
|
* `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
|
* no persisted per-recipe locale to recover for a step that already exists,
|
||||||
* exists, so this matches real resave behavior exactly rather than
|
* so this matches real resave behavior exactly rather than guessing).
|
||||||
* 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 a previous session added) keeps
|
* `contextStart`/`contextEnd` columns this same session added) keeps
|
||||||
* whatever it was matched with at the time until it's next resaved.
|
* whatever it was matched with at the time until it's next resaved. Run
|
||||||
|
* this after a corpus change to bring every existing step in sync without
|
||||||
|
* asking users to open and resave every recipe by hand:
|
||||||
*
|
*
|
||||||
* `"manual"`-sourced entries (a viewer's correction, applied immediately —
|
* pnpm --filter api exec tsx src/scripts/backfill-tech-steps.ts
|
||||||
* see `recipe-tech-step-correction.service.ts`'s `applyManualCorrection`)
|
|
||||||
* are never touched by this: only rows with `source: "auto"` are deleted
|
|
||||||
* and recreated, and any fresh classifier match overlapping an existing
|
|
||||||
* `"manual"` entry's span is dropped rather than inserted — a manual
|
|
||||||
* correction is meant to *override* the classifier at that exact spot,
|
|
||||||
* and recomputing must never silently reintroduce (or duplicate-highlight)
|
|
||||||
* what a user already corrected. `renumberStepTechSteps`
|
|
||||||
* (`recipe-tech-step-correction.service.ts`) folds the surviving `"auto"` +
|
|
||||||
* untouched `"manual"` rows back into one coherent reading-order sequence
|
|
||||||
* afterward.
|
|
||||||
*
|
*
|
||||||
* Exported (not just called from this file's own CLI guard below) so
|
* Safe to re-run: each step's technique sequence is fully replaced (delete
|
||||||
* `retrain-tech-steps.ts` can run it as one step of its own larger
|
* + recreate) from the classifier's current output, same as a real edit —
|
||||||
* maintainer workflow, without shelling out to a second process.
|
* running it twice in a row with no corpus change in between is a no-op.
|
||||||
*
|
|
||||||
* Safe to re-run: with no manual entries and no corpus change since the
|
|
||||||
* last run, this is a no-op (the same `"auto"` matches get deleted and
|
|
||||||
* recreated identically); with manual entries present, they're preserved
|
|
||||||
* on every run by construction.
|
|
||||||
*/
|
*/
|
||||||
export async function backfillTechSteps(): Promise<{ total: number; changed: number }> {
|
async function backfillTechSteps(): Promise<void> {
|
||||||
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([
|
||||||
await prisma.$transaction(async (tx) => {
|
prisma.stepTechStep.deleteMany({ where: { stepId: step.id } }),
|
||||||
const manualRows = await tx.stepTechStep.findMany({
|
prisma.stepTechStep.createMany({
|
||||||
where: { stepId: step.id, source: "manual" },
|
data: matches.map((match, order) => ({
|
||||||
});
|
stepId: step.id,
|
||||||
|
techStepId: match.techStepId,
|
||||||
const nonOverlappingMatches = matches.filter(
|
order,
|
||||||
(match) =>
|
start: match.start,
|
||||||
!manualRows.some(
|
end: match.end,
|
||||||
(manual) =>
|
contextStart: match.contextStart,
|
||||||
manual.start !== null &&
|
contextEnd: match.contextEnd,
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only runs when this file is executed directly (`tsx
|
backfillTechSteps()
|
||||||
// src/scripts/backfill-tech-steps.ts`), not when `retrain-tech-steps.ts`
|
.then(() => prisma.$disconnect())
|
||||||
// imports `backfillTechSteps` above — the standard ESM "is this the entry
|
.catch(async (err) => {
|
||||||
// module" check, first needed in this codebase by that new script; every
|
console.error(err);
|
||||||
// prior script here (`seed-runtime.ts`) was always only ever run directly,
|
await prisma.$disconnect();
|
||||||
// never imported. `pathToFileURL` (not a naive `` `file://${process.argv[1]}` ``
|
process.exit(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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import { prisma } from "../db/prisma.js";
|
|
||||||
import { TECH_STEP_EVAL_DATASET } from "../lib/recipe-matching/tech-step-eval-dataset.js";
|
|
||||||
import {
|
|
||||||
computeTechStepMetrics,
|
|
||||||
type TechStepEvalOutcome,
|
|
||||||
} from "../lib/recipe-matching/tech-step-evaluator.js";
|
|
||||||
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Candidate thresholds to sweep, `0.05` to `0.95` in `0.05` steps — fine
|
|
||||||
* enough to find a good value without an unreasonable number of full
|
|
||||||
* `TECH_STEP_EVAL_DATASET` passes (each threshold only needs one
|
|
||||||
* {@link techStepClassifier.classifyClauses} call per eval case, not a
|
|
||||||
* retrain — see this file's own doc comment for why).
|
|
||||||
*/
|
|
||||||
const CANDIDATE_THRESHOLDS = Array.from({ length: 19 }, (_, i) => Math.round((i + 1) * 5) / 100);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One-off maintainer tool for recalibrating `CONFIDENCE_THRESHOLD`
|
|
||||||
* (`tech-step-matcher.ts`) after a change to the underlying intent
|
|
||||||
* classifier — most notably, the migration from `node-nlp` to
|
|
||||||
* `services/tech-step-intent-service` (spaCy): a different model produces a
|
|
||||||
* differently-shaped confidence score distribution, so a threshold tuned
|
|
||||||
* against the old classifier has no reason to still be the right cutoff for
|
|
||||||
* the new one.
|
|
||||||
*
|
|
||||||
* Reuses `techStepClassifier.classifyClauses` — already public, and
|
|
||||||
* deliberately *not* threshold-applied (see that method's own doc comment)
|
|
||||||
* — to get every eval case's raw `{anchorUid, intentUid, score}` per clause
|
|
||||||
* exactly once, then replays `_classifyClause`'s own decision rule
|
|
||||||
* (`intentUid` if confident enough, `anchorUid` otherwise) locally in this
|
|
||||||
* script for every candidate threshold. This is what makes a full sweep
|
|
||||||
* cheap: one classifier pass per eval case regardless of how many
|
|
||||||
* thresholds are being compared, rather than one full pass *per threshold*.
|
|
||||||
*
|
|
||||||
* Prints a threshold -> precision/recall/F1 table and the threshold that
|
|
||||||
* maximizes aggregate F1 — does **not** edit `tech-step-matcher.ts` itself.
|
|
||||||
* A maintainer reads the table, updates `CONFIDENCE_THRESHOLD` by hand (with
|
|
||||||
* an updated doc comment recording what run/F1 the new value was calibrated
|
|
||||||
* against, same as the existing comment's own format), then re-runs
|
|
||||||
* `retrain-tech-steps.ts` to confirm the change clears `MIN_OVERALL_F1`.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
*
|
|
||||||
* pnpm --filter api exec tsx src/scripts/calibrate-tech-step-threshold.ts
|
|
||||||
*/
|
|
||||||
async function calibrateTechStepThreshold(): Promise<void> {
|
|
||||||
console.info(`Classifying ${TECH_STEP_EVAL_DATASET.length} eval case(s)...`);
|
|
||||||
|
|
||||||
// One classifier pass per eval case, all clauses' raw verdicts kept
|
|
||||||
// alongside the case's own `expectedKeys` — reused for every candidate
|
|
||||||
// threshold in the loop below.
|
|
||||||
const casesWithClauses = await Promise.all(
|
|
||||||
TECH_STEP_EVAL_DATASET.map(async (evalCase) => ({
|
|
||||||
expectedKeys: evalCase.expectedKeys,
|
|
||||||
clauses: await techStepClassifier.classifyClauses(evalCase.description, evalCase.locale),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
console.info("\nthreshold precision recall f1");
|
|
||||||
let bestThreshold = CANDIDATE_THRESHOLDS[0] ?? 0;
|
|
||||||
let bestF1 = -1;
|
|
||||||
|
|
||||||
for (const threshold of CANDIDATE_THRESHOLDS) {
|
|
||||||
const outcomes: TechStepEvalOutcome[] = casesWithClauses.map(({ expectedKeys, clauses }) => {
|
|
||||||
const actualKeys = clauses
|
|
||||||
// Mirrors `_classifyClause`'s own decision rule exactly (see that
|
|
||||||
// method, `tech-step-matcher.ts`) — the classifier's own verdict
|
|
||||||
// when confident enough, otherwise its clause's NER anchor, `null`
|
|
||||||
// when neither applies (no keyword, no confident classification).
|
|
||||||
.map((clause) =>
|
|
||||||
clause.intentUid !== null && clause.score >= threshold
|
|
||||||
? clause.intentUid
|
|
||||||
: clause.anchorUid,
|
|
||||||
)
|
|
||||||
.filter((key): key is string => key !== null);
|
|
||||||
return { expectedKeys, actualKeys };
|
|
||||||
});
|
|
||||||
|
|
||||||
const { overall } = computeTechStepMetrics(outcomes);
|
|
||||||
console.info(
|
|
||||||
`${threshold.toFixed(2)} ${overall.precision.toFixed(3)} ${overall.recall.toFixed(3)} ${overall.f1.toFixed(3)}`,
|
|
||||||
);
|
|
||||||
if (overall.f1 > bestF1) {
|
|
||||||
bestF1 = overall.f1;
|
|
||||||
bestThreshold = threshold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info(
|
|
||||||
`\nBest aggregate F1 ${bestF1.toFixed(3)} at threshold ${bestThreshold.toFixed(2)} — update CONFIDENCE_THRESHOLD in tech-step-matcher.ts by hand if this differs from the current value.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
calibrateTechStepThreshold()
|
|
||||||
.then(() => prisma.$disconnect())
|
|
||||||
.catch(async (err) => {
|
|
||||||
console.error(err);
|
|
||||||
await prisma.$disconnect();
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import { prisma } from "../db/prisma.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maintainer-facing report of every `TechStepTrainingSuggestion` still
|
|
||||||
* `status: "pending"` (`TechStepTrainingSuggestion`'s own schema doc
|
|
||||||
* comment) — generated by `services/tech-step-llm-worker`'s scheduled
|
|
||||||
* jobs, from either a user correction or the worker's own low-confidence
|
|
||||||
* audit (`sourceType`). What a maintainer reads *before* hand-editing
|
|
||||||
* `services/tech-step-intent-service/intent_service/training_data.py` and
|
|
||||||
* running `retrain-tech-steps.ts` — this script never writes anything,
|
|
||||||
* purely a read-only report to stdout:
|
|
||||||
*
|
|
||||||
* pnpm --filter api exec tsx src/scripts/list-pending-training-suggestions.ts
|
|
||||||
*
|
|
||||||
* Grouped by technique key so every suggestion for the same entry in
|
|
||||||
* `training_data.py`'s `TECH_STEP_TRAINING_DATA` is read together, matching
|
|
||||||
* how that file itself is organized (one block per technique).
|
|
||||||
*/
|
|
||||||
async function listPendingTrainingSuggestions(): Promise<void> {
|
|
||||||
const suggestions = await prisma.techStepTrainingSuggestion.findMany({
|
|
||||||
where: { status: "pending" },
|
|
||||||
orderBy: [{ techStepId: "asc" }, { createdAt: "asc" }],
|
|
||||||
include: { techStep: { select: { key: true } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (suggestions.length === 0) {
|
|
||||||
console.info("No pending training suggestions.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const byTechStepKey = new Map<string, typeof suggestions>();
|
|
||||||
for (const suggestion of suggestions) {
|
|
||||||
const key = suggestion.techStep.key;
|
|
||||||
const group = byTechStepKey.get(key);
|
|
||||||
if (group) {
|
|
||||||
group.push(suggestion);
|
|
||||||
} else {
|
|
||||||
byTechStepKey.set(key, [suggestion]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = [`# Pending tech-step training suggestions (${suggestions.length})`, ""];
|
|
||||||
for (const [techStepKey, group] of byTechStepKey) {
|
|
||||||
lines.push(`## ${techStepKey}`, "");
|
|
||||||
for (const suggestion of group) {
|
|
||||||
const source =
|
|
||||||
suggestion.sourceCorrectionId !== null
|
|
||||||
? `${suggestion.sourceType} (correction #${suggestion.sourceCorrectionId})`
|
|
||||||
: suggestion.sourceType;
|
|
||||||
lines.push(`- id ${suggestion.id} · locale ${suggestion.locale} · source: ${source}`);
|
|
||||||
if (suggestion.suggestedSynonyms.length > 0) {
|
|
||||||
lines.push(` - synonyms: ${suggestion.suggestedSynonyms.join(", ")}`);
|
|
||||||
}
|
|
||||||
if (suggestion.suggestedUtterances.length > 0) {
|
|
||||||
lines.push(
|
|
||||||
` - utterances: ${suggestion.suggestedUtterances.map((u) => `"${u}"`).join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info(lines.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
listPendingTrainingSuggestions()
|
|
||||||
.then(() => prisma.$disconnect())
|
|
||||||
.catch(async (err) => {
|
|
||||||
console.error(err);
|
|
||||||
await prisma.$disconnect();
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import { prisma } from "../db/prisma.js";
|
|
||||||
import {
|
|
||||||
MIN_OVERALL_F1,
|
|
||||||
runTechStepEvalSuite,
|
|
||||||
} from "../lib/recipe-matching/tech-step-eval-runner.js";
|
|
||||||
import { backfillTechSteps } from "./backfill-tech-steps.js";
|
|
||||||
|
|
||||||
/** Parses `--applied=1,2,3`/`--rejected=4,5` from argv into id arrays — both optional, both empty by default (a run with neither flag only re-gates + backfills, doesn't touch any suggestion's status). */
|
|
||||||
function parseSuggestionIds(flag: "applied" | "rejected"): number[] {
|
|
||||||
const prefix = `--${flag}=`;
|
|
||||||
const arg = process.argv.find((value) => value.startsWith(prefix));
|
|
||||||
if (arg === undefined) return [];
|
|
||||||
return arg
|
|
||||||
.slice(prefix.length)
|
|
||||||
.split(",")
|
|
||||||
.map((value) => value.trim())
|
|
||||||
.filter((value) => value.length > 0)
|
|
||||||
.map((value) => {
|
|
||||||
const id = Number(value);
|
|
||||||
if (!Number.isInteger(id)) {
|
|
||||||
throw new Error(`--${flag}: "${value}" is not a valid integer id`);
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maintainer workflow closing the loop on a training-corpus change (see
|
|
||||||
* this feature's plan document):
|
|
||||||
*
|
|
||||||
* 1. A maintainer has already hand-edited
|
|
||||||
* `services/tech-step-intent-service/intent_service/training_data.py`
|
|
||||||
* (informed by `list-pending-training-suggestions.ts`'s report),
|
|
||||||
* decided which `TechStepTrainingSuggestion` ids they incorporated
|
|
||||||
* (`--applied=`) or explicitly discarded (`--rejected=`), **and
|
|
||||||
* restarted `tech-step-intent-service`** so it retrains from the
|
|
||||||
* edited corpus — that service only ever trains once, at its own
|
|
||||||
* startup (see its README), so this script's eval gate below is
|
|
||||||
* meaningless against a service still running the old corpus.
|
|
||||||
* 2. This script re-runs the F1 regression gate
|
|
||||||
* ({@link runTechStepEvalSuite} against {@link MIN_OVERALL_F1}) —
|
|
||||||
* refuses to backfill at all if the edited corpus scores worse than
|
|
||||||
* the floor, so a bad edit never reaches every existing recipe.
|
|
||||||
* 3. Backfills every `Step`'s `StepTechStep` sequence against the new
|
|
||||||
* corpus ({@link backfillTechSteps}).
|
|
||||||
* 4. Marks the given suggestion ids `applied`/`rejected`, so
|
|
||||||
* `list-pending-training-suggestions.ts`'s next report doesn't
|
|
||||||
* surface them again.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
*
|
|
||||||
* pnpm --filter api exec tsx src/scripts/retrain-tech-steps.ts --applied=12,13 --rejected=14
|
|
||||||
*
|
|
||||||
* `--applied`/`--rejected` are both optional — omitting both still runs
|
|
||||||
* the gate + backfill, just leaves every suggestion's `status` untouched
|
|
||||||
* (useful for re-running the backfill alone after a corpus edit made with
|
|
||||||
* no suggestions involved at all).
|
|
||||||
*/
|
|
||||||
async function retrainTechSteps(): Promise<void> {
|
|
||||||
const appliedIds = parseSuggestionIds("applied");
|
|
||||||
const rejectedIds = parseSuggestionIds("rejected");
|
|
||||||
|
|
||||||
console.info("Evaluating the current classifier against the labeled evaluation set...");
|
|
||||||
const { overall } = await runTechStepEvalSuite();
|
|
||||||
console.info(
|
|
||||||
`F1 ${overall.f1.toFixed(3)} (precision ${overall.precision.toFixed(3)}, recall ${overall.recall.toFixed(3)})`,
|
|
||||||
);
|
|
||||||
if (overall.f1 < MIN_OVERALL_F1) {
|
|
||||||
throw new Error(
|
|
||||||
`Aggregate F1 ${overall.f1.toFixed(3)} is below the ${MIN_OVERALL_F1} regression floor — refusing to backfill. Revert or fix the corpus change and re-run.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { total, changed } = await backfillTechSteps();
|
|
||||||
console.info(`Backfilled ${changed}/${total} step(s).`);
|
|
||||||
|
|
||||||
if (appliedIds.length > 0) {
|
|
||||||
// `updateMany`'s own `count` (rows actually matched/updated), not
|
|
||||||
// `appliedIds.length` (what was merely *asked for*) — an id that
|
|
||||||
// doesn't exist (typo, already-processed id) would otherwise log a
|
|
||||||
// success count that silently doesn't match what really changed.
|
|
||||||
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
|
|
||||||
where: { id: { in: appliedIds } },
|
|
||||||
data: { status: "applied" },
|
|
||||||
});
|
|
||||||
console.info(`Marked ${count}/${appliedIds.length} suggestion(s) as applied.`);
|
|
||||||
}
|
|
||||||
if (rejectedIds.length > 0) {
|
|
||||||
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
|
|
||||||
where: { id: { in: rejectedIds } },
|
|
||||||
data: { status: "rejected" },
|
|
||||||
});
|
|
||||||
console.info(`Marked ${count}/${rejectedIds.length} suggestion(s) as rejected.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
retrainTechSteps()
|
|
||||||
.then(() => prisma.$disconnect())
|
|
||||||
.catch(async (err) => {
|
|
||||||
console.error(err);
|
|
||||||
await prisma.$disconnect();
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -9,45 +9,22 @@ 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
|
||||||
* Trains the tech-step classifier (a `POST /v1/train` round-trip per locale
|
// setup cost — see `TechStepClassifierService.warmUp`) before accepting
|
||||||
* to `services/tech-step-intent-service` — see
|
// any traffic, so the first real recipe save/preview isn't the one stuck
|
||||||
* `TechStepClassifierService.warmUp`) before accepting any traffic, so the
|
// waiting several seconds for it.
|
||||||
* first real recipe save/preview isn't the one stuck waiting for it.
|
try {
|
||||||
*
|
await techStepClassifier.warmUp();
|
||||||
* Retried with exponential backoff: in Docker Compose, `app`'s own
|
} catch (err) {
|
||||||
* `depends_on: tech-step-intent-service: condition: service_healthy`
|
// Not fatal to startup — a failed warm-up just means the *next* call
|
||||||
* (`docker-compose.yml`) already means that service is up by the time this
|
// retries training itself (see `_ensureTrained`'s own retry-on-failure
|
||||||
* runs, but native dev (`pnpm dev:api`, no Compose ordering at all) can
|
// comment), same graceful-degrade posture as everywhere else training
|
||||||
* easily start this before the intent service has finished loading its
|
// failures surface. Still worth a loud log: this shouldn't normally fail.
|
||||||
* spaCy models — a transient connection failure here shouldn't need a
|
logger.error("Tech-step classifier warm-up failed", {
|
||||||
* manual restart. Still non-fatal after every attempt is exhausted: the
|
error: err instanceof Error ? err.message : String(err),
|
||||||
* *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, () => {
|
||||||
|
|
|
||||||
|
|
@ -1,425 +0,0 @@
|
||||||
import type {
|
|
||||||
ParsedRecipe,
|
|
||||||
RecipeSourceAdapter,
|
|
||||||
RecipeSourceListItem,
|
|
||||||
RecipeSourceListParams,
|
|
||||||
RecipeSourceListResult,
|
|
||||||
} from "../lib/recipe-sources/recipe-source-adapter.js";
|
|
||||||
import {
|
|
||||||
RecipeSourceFetchError,
|
|
||||||
RecipeSourceParseError,
|
|
||||||
} from "../lib/recipe-sources/recipe-source-errors.js";
|
|
||||||
import { jsonLdRecipeAdapter } from "./json-ld-recipe.js";
|
|
||||||
|
|
||||||
const SOURCE_KEY = "750g";
|
|
||||||
|
|
||||||
// 750g.com's own site search is a client-side widget (results are fetched
|
|
||||||
// by the page's own JS after load, nothing server-rendered to scrape) — but
|
|
||||||
// that JS itself calls this plain GET endpoint, an "AI answer engine" that
|
|
||||||
// returns an HTML fragment of recipe cards for a free-text query. Verified
|
|
||||||
// live: works with a bare `fetch`, no special headers/cookies/session
|
|
||||||
// needed, same as every other adapter in this family. Only used for a
|
|
||||||
// non-empty query — see `LATEST_RECIPES_URL` for why: this endpoint answers
|
|
||||||
// a blank query with nothing at all.
|
|
||||||
const SEARCH_URL = "https://www.750g.com/genius/query/";
|
|
||||||
|
|
||||||
// What `list()` reads instead of `SEARCH_URL` for an empty/omitted `query`
|
|
||||||
// ("browse everything", per `RecipeSourceListParams.query`'s own doc
|
|
||||||
// comment) — verified live, `SEARCH_URL` responds to a blank query with a
|
|
||||||
// zero-length body, so browsing this source with no filter typed would
|
|
||||||
// otherwise always come back empty. `dernieres-recettes.htm` is 750g.com's
|
|
||||||
// own "latest recipes" archive: real, server-rendered pagination via
|
|
||||||
// `&page=N` (unlike `SEARCH_URL`, which doesn't paginate at all — see
|
|
||||||
// `list()`'s own comment on `nextCursor`), same `card-recipe`/`card-link`
|
|
||||||
// markup `extractRecipeCards` already reads elsewhere on the site. Checked
|
|
||||||
// live up to `page=500` — genuinely different recipes every time, no
|
|
||||||
// redirect/clamp once past whatever the real end is (unlike marmiton.ts's
|
|
||||||
// search, which 404s past its last page), so `list()` treats a page with no
|
|
||||||
// cards at all as the end-of-results signal instead.
|
|
||||||
const LATEST_RECIPES_URL = "https://www.750g.com/dernieres-recettes.htm";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Matches every `<script type="application/ld+json">…</script>` block —
|
|
||||||
* same shape as `JSON_LD_SCRIPT_PATTERN` in json-ld-recipe.ts, kept as its
|
|
||||||
* own private copy here rather than sharing that module's export: this one
|
|
||||||
* does textual surgery on the *raw HTML* before `jsonLdRecipeAdapter` ever
|
|
||||||
* sees it (see {@link sanitizeJsonLdBlocks} below), a different concern
|
|
||||||
* from extracting-and-parsing blocks into objects.
|
|
||||||
*/
|
|
||||||
const JSON_LD_SCRIPT_PATTERN =
|
|
||||||
/(<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>)([\s\S]*?)(<\/script>)/gi;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escapes any raw (unescaped) JSON control character — U+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,15 +1,12 @@
|
||||||
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 (`recipe-source-registry.ts`)
|
* ships with into the shared in-memory registry
|
||||||
* — `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter` and
|
* (`recipe-source-registry.ts`) — currently just `theMealDbAdapter`.
|
||||||
* `mangerBougerAdapter`. Called once, explicitly, by the two real entry
|
* Called once, explicitly, by the two real entry points that need the
|
||||||
* points that need the registry populated:
|
* 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
|
||||||
|
|
@ -24,19 +21,15 @@ 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) itself is deliberately **not**
|
* `jsonLdRecipeAdapter` (json-ld-recipe.ts) is deliberately **not**
|
||||||
* registered here — it's a generic schema.org-JSON-LD parser meant to be
|
* registered here — it's a generic schema.org-JSON-LD parser meant to be
|
||||||
* specialized per scraped website, not a household-toggleable `Source` in
|
* specialized per scraped website (a concrete adapter for a specific site
|
||||||
* its own right: nobody can meaningfully "trust" or "enable" a generic
|
* would use it internally), not a household-toggleable `Source` in its own
|
||||||
* parsing mechanism the way they can a named website. `marmitonAdapter`
|
* right: nobody can meaningfully "trust" or "enable" a generic parsing
|
||||||
* (marmiton.ts), `sevenFiftyGAdapter` (750g.ts) and `mangerBougerAdapter`
|
* mechanism the way they can a named website. Until real per-site adapters
|
||||||
* (manger-bouger.ts) are exactly that specialization, one per site — the
|
* exist, it's called directly (e.g. a future "import from a pasted URL"
|
||||||
* concrete adapters its own doc comment anticipated ("a concrete adapter
|
* flow), never through this registry.
|
||||||
* 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,17 +44,8 @@ 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`). */
|
||||||
* Extracts and JSON-parses every JSON-LD block on the page — a block that
|
function extractJsonLdBlocks(html: string): unknown[] {
|
||||||
* 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 {
|
||||||
|
|
@ -176,17 +167,6 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -1,355 +0,0 @@
|
||||||
import type {
|
|
||||||
ParsedRecipe,
|
|
||||||
RecipeSourceAdapter,
|
|
||||||
RecipeSourceListItem,
|
|
||||||
RecipeSourceListParams,
|
|
||||||
RecipeSourceListResult,
|
|
||||||
} from "../lib/recipe-sources/recipe-source-adapter.js";
|
|
||||||
import {
|
|
||||||
RecipeSourceFetchError,
|
|
||||||
RecipeSourceParseError,
|
|
||||||
} from "../lib/recipe-sources/recipe-source-errors.js";
|
|
||||||
import { jsonLdRecipeAdapter } from "./json-ld-recipe.js";
|
|
||||||
|
|
||||||
const SOURCE_KEY = "mangerBouger";
|
|
||||||
|
|
||||||
// "La Fabrique à Menus" — mangerbouger.fr's recipe tool (Santé publique
|
|
||||||
// France). Its listing page is a Next.js app with no JSON-LD `ItemList` at
|
|
||||||
// all (unlike marmiton.ts's search page) — but it's server-rendered, and a
|
|
||||||
// plain GET carries the exact same Redux state the client hydrates from as
|
|
||||||
// a `__NEXT_DATA__` script tag (see `extractNextData` below), which already
|
|
||||||
// has everything `list()` needs. Verified live: `?query=<free text>` really
|
|
||||||
// filters server-side (not just a client-side URL update over an
|
|
||||||
// already-fetched page), and `page`/`hasMorePages` behave as real,
|
|
||||||
// consistent pagination — the best-behaved of this adapter family's three
|
|
||||||
// sources on that front.
|
|
||||||
const LIST_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes";
|
|
||||||
const DETAIL_BASE_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes/";
|
|
||||||
|
|
||||||
/** Matches the `<script id="__NEXT_DATA__">…</script>` block every Next.js page ships — the site's own server-rendered hydration data, read instead of scraping HTML for both `list()` (the listing's recipe cards) and `parse()` (backfilling a gap in the detail page's JSON-LD, see {@link extractPortionsFromNextData}). */
|
|
||||||
const NEXT_DATA_PATTERN = /<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/;
|
|
||||||
|
|
||||||
/** The one field of one `list[]` entry `list()` actually reads off the listing page's `__NEXT_DATA__` — that state carries the site's full internal `Recipe` shape (60+ fields: nutriscore, seasons, macros, …), none of which this adapter's contract has anywhere to put. */
|
|
||||||
interface MangerBougerListEntry {
|
|
||||||
slug?: string;
|
|
||||||
name?: string;
|
|
||||||
image?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The slice of `__NEXT_DATA__` this module reads off the *listing* page. */
|
|
||||||
interface MangerBougerListPageData {
|
|
||||||
props?: {
|
|
||||||
initialState?: {
|
|
||||||
recipes?: {
|
|
||||||
list?: MangerBougerListEntry[];
|
|
||||||
/** Whether a further page exists for the current `page`/`query`/`diet` combination — verified live: an out-of-range page comes back `false` with an empty `list` rather than repeating the last page or erroring, a cleaner end-of-results signal than either `marmiton.ts` (infers it from a 404) or `750g.ts` (this search has no real pagination at all). */
|
|
||||||
hasMorePages?: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The slice of `__NEXT_DATA__` this module reads off a recipe *detail* page — a different shape than the listing page's (`initialState.recipe.recipe`, not `initialState.recipes.list[]`) since it's a different Redux slice entirely. */
|
|
||||||
interface MangerBougerDetailPageData {
|
|
||||||
props?: {
|
|
||||||
initialState?: {
|
|
||||||
recipe?: {
|
|
||||||
recipe?: {
|
|
||||||
portions?: unknown;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parses the page's `__NEXT_DATA__` block into `T`, or `null` if the block is missing or isn't valid JSON — callers degrade gracefully rather than throw, same as `marmiton.ts`'s "page has no ItemList at all" handling. */
|
|
||||||
function extractNextData<T>(html: string): T | null {
|
|
||||||
const match = html.match(NEXT_DATA_PATTERN);
|
|
||||||
if (!match) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(match[1] ?? "") as T;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function detailUrl(slug: string): string {
|
|
||||||
return `${DETAIL_BASE_URL}${slug}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Matches the single `<script type="application/ld+json">…</script>` block
|
|
||||||
* a mangerbouger.fr recipe *detail* page carries (verified live across a
|
|
||||||
* sample of 9 recipes — always exactly one, always a bare `Recipe`, never
|
|
||||||
* an `@graph`) — a much narrower pattern than `json-ld-recipe.ts`'s own
|
|
||||||
* `JSON_LD_SCRIPT_PATTERN` (no `g` flag: this module only ever needs the
|
|
||||||
* first/only block, to patch it — see {@link patchRecipeJsonLd}) or
|
|
||||||
* `750g.ts`'s identically-named private copy (which does its own,
|
|
||||||
* different, character-level repair over every block on the page).
|
|
||||||
*/
|
|
||||||
const JSON_LD_SCRIPT_PATTERN =
|
|
||||||
/(<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>)([\s\S]*?)(<\/script>)/i;
|
|
||||||
|
|
||||||
/** One node of a Slate.js rich-text document — see {@link flattenSlateDocument}. */
|
|
||||||
interface SlateNode {
|
|
||||||
type?: string;
|
|
||||||
text?: string;
|
|
||||||
children?: SlateNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Concatenates a run of inline Slate nodes (leaf text, or further-nested inline runs) with no separator — bold/italic/underline marks (the only ones observed) carry no plain-text equivalent and are simply dropped. */
|
|
||||||
function flattenSlateInline(nodes: SlateNode[]): string {
|
|
||||||
return nodes
|
|
||||||
.map((node) =>
|
|
||||||
typeof node.text === "string"
|
|
||||||
? node.text
|
|
||||||
: node.children
|
|
||||||
? flattenSlateInline(node.children)
|
|
||||||
: "",
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flattens a Slate.js document's top-level blocks into one line of plain
|
|
||||||
* text each — verified live across every recipe step sampled (72 recipes):
|
|
||||||
* only `paragraph` and `bulleted-list` (of `list-item`s) ever appear as
|
|
||||||
* block types, so that's all this handles; any other/unrecognized block
|
|
||||||
* type still degrades reasonably (its own children read as one inline run)
|
|
||||||
* rather than being dropped outright.
|
|
||||||
*/
|
|
||||||
function flattenSlateBlocks(nodes: SlateNode[]): string[] {
|
|
||||||
const lines: string[] = [];
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.type === "bulleted-list" && node.children) {
|
|
||||||
lines.push(...flattenSlateBlocks(node.children));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (node.type === "list-item" && node.children) {
|
|
||||||
const text = flattenSlateInline(node.children);
|
|
||||||
if (text.trim().length > 0) lines.push(`- ${text}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (node.children) {
|
|
||||||
const text = flattenSlateInline(node.children);
|
|
||||||
if (text.trim().length > 0) lines.push(text);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (typeof node.text === "string" && node.text.trim().length > 0) lines.push(node.text);
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flattens one `HowToStep.text` value into plain text. mangerbouger.fr's
|
|
||||||
* own JSON-LD embeds this field pre-formatted for its own web app instead
|
|
||||||
* of as prose: `text` is itself a JSON-serialized Slate.js rich-text
|
|
||||||
* document (verified live: every one of 72 sampled recipe steps parses as
|
|
||||||
* one) — handing that straight to `jsonLdRecipeAdapter.parse` would surface
|
|
||||||
* the raw `[{"type":"paragraph","children":[{"text":"…` blob as a step's
|
|
||||||
* description, unusable as-is. `json` that doesn't parse as an array (a
|
|
||||||
* genuinely plain-text step, or some future/different shape) is returned
|
|
||||||
* unchanged rather than mangled.
|
|
||||||
*/
|
|
||||||
function flattenSlateDocument(json: string): string {
|
|
||||||
let doc: unknown;
|
|
||||||
try {
|
|
||||||
doc = JSON.parse(json);
|
|
||||||
} catch {
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
if (!Array.isArray(doc)) return json;
|
|
||||||
return flattenSlateBlocks(doc as SlateNode[]).join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The two schema.org `Recipe` fields {@link patchRecipeJsonLd} patches, plus an index signature so every other field survives re-serialization untouched. */
|
|
||||||
interface JsonLdRecipeLike {
|
|
||||||
recipeInstructions?: unknown;
|
|
||||||
recipeYield?: unknown;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One `HowToStep`-shaped entry of `recipeInstructions`, as far as {@link patchRecipeJsonLd} needs to know. */
|
|
||||||
interface JsonLdHowToStepLike {
|
|
||||||
text?: unknown;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `state.recipe.recipe.portions` from the same detail page's `__NEXT_DATA__`
|
|
||||||
* — the number `recipeYield` should have been (see {@link patchRecipeJsonLd}),
|
|
||||||
* read from the site's own internal state rather than left unstated.
|
|
||||||
*/
|
|
||||||
function extractPortionsFromNextData(html: string): number | null {
|
|
||||||
const data = extractNextData<MangerBougerDetailPageData>(html);
|
|
||||||
const portions = data?.props?.initialState?.recipe?.recipe?.portions;
|
|
||||||
return typeof portions === "number" ? portions : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Repairs the two real gaps verified live in mangerbouger.fr's own
|
|
||||||
* recipe-detail JSON-LD, then hands the patched HTML to
|
|
||||||
* `jsonLdRecipeAdapter.parse` unmodified otherwise — same "fix what's
|
|
||||||
* actually broken, delegate the rest" shape as `750g.ts`'s
|
|
||||||
* `sanitizeJsonLdBlocks`/`decodeParsedRecipeText`, just structural (parse →
|
|
||||||
* mutate → re-serialize the one JSON-LD object) rather than textual, since
|
|
||||||
* both gaps need real understanding of the document, not character-level
|
|
||||||
* fixups:
|
|
||||||
*
|
|
||||||
* - `recipeInstructions[].text` is Slate.js rich text, not prose — flattened
|
|
||||||
* via {@link flattenSlateDocument}.
|
|
||||||
* - `recipeYield` is absent on every one of 9 sampled recipes (schema.org
|
|
||||||
* allows omitting it, and mangerbouger.fr's generator apparently always
|
|
||||||
* does) even though the site's own internal data has the serving count
|
|
||||||
* right there — backfilled from `__NEXT_DATA__` via
|
|
||||||
* {@link extractPortionsFromNextData} rather than left as a needless
|
|
||||||
* `portions: null` on every single imported recipe.
|
|
||||||
*
|
|
||||||
* A missing or malformed JSON-LD block is left completely untouched —
|
|
||||||
* `jsonLdRecipeAdapter`'s own "no JSON-LD Recipe found"/"malformed block,
|
|
||||||
* skip it" handling is exactly the right behavior for that, no need to
|
|
||||||
* duplicate it here.
|
|
||||||
*/
|
|
||||||
function patchRecipeJsonLd(html: string): string {
|
|
||||||
const match = html.match(JSON_LD_SCRIPT_PATTERN);
|
|
||||||
if (!match) return html;
|
|
||||||
|
|
||||||
let recipe: JsonLdRecipeLike;
|
|
||||||
try {
|
|
||||||
recipe = JSON.parse(match[2] ?? "{}") as JsonLdRecipeLike;
|
|
||||||
} catch {
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(recipe.recipeInstructions)) {
|
|
||||||
for (const step of recipe.recipeInstructions as JsonLdHowToStepLike[]) {
|
|
||||||
if (step && typeof step === "object" && typeof step.text === "string") {
|
|
||||||
step.text = flattenSlateDocument(step.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recipe.recipeYield === undefined) {
|
|
||||||
const portions = extractPortionsFromNextData(html);
|
|
||||||
if (portions !== null) recipe.recipeYield = portions;
|
|
||||||
}
|
|
||||||
|
|
||||||
const patchedJson = JSON.stringify(recipe);
|
|
||||||
return html.replace(
|
|
||||||
JSON_LD_SCRIPT_PATTERN,
|
|
||||||
(_full, openTag: string, _json: string, closeTag: string) =>
|
|
||||||
`${openTag}${patchedJson}${closeTag}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
|
|
||||||
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having
|
|
||||||
* come from this adapter instead (`sourceKey` `"mangerBouger"`) — same
|
|
||||||
* reasoning as `marmiton.ts`/`750g.ts`'s identically-named helpers.
|
|
||||||
*/
|
|
||||||
function rekeySourceError(err: unknown): unknown {
|
|
||||||
if (err instanceof RecipeSourceFetchError) {
|
|
||||||
return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause });
|
|
||||||
}
|
|
||||||
if (err instanceof RecipeSourceParseError) {
|
|
||||||
return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause });
|
|
||||||
}
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* mangerbouger.fr ("La Fabrique à Menus") — Santé publique France's public
|
|
||||||
* nutrition site. Unofficial (`official: false`): no published API, same
|
|
||||||
* reasoning as every other adapter in this family — fetching ordinary pages
|
|
||||||
* and reading data the site never committed to a stable contract, not a
|
|
||||||
* maintained endpoint. `fetchDetail` delegates straight to
|
|
||||||
* `jsonLdRecipeAdapter`; `parse` wraps it with {@link patchRecipeJsonLd}
|
|
||||||
* (see that function's doc comment for the two real gaps it fixes).
|
|
||||||
* `list()` doesn't use JSON-LD at all — see `LIST_URL`'s doc comment.
|
|
||||||
*/
|
|
||||||
export const mangerBougerAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
|
|
||||||
key: SOURCE_KEY,
|
|
||||||
name: "Manger Bouger",
|
|
||||||
official: false,
|
|
||||||
// Chemin fixe (pas d'icône versionnée/hashée comme sur d'autres sources
|
|
||||||
// de cette famille) — répond correctement sans paramètre supplémentaire.
|
|
||||||
iconUrl: "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/favicon.ico",
|
|
||||||
// Le contenu de mangerbouger.fr (noms, ingrédients, instructions) est en
|
|
||||||
// français — détermine contre quel modèle/locale d'étiquettes
|
|
||||||
// d'ingrédients translateRecipe (recipe-translation.ts) résout les
|
|
||||||
// recettes de cette source lors d'une prévisualisation/d'un import.
|
|
||||||
locale: "fr",
|
|
||||||
|
|
||||||
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
|
||||||
try {
|
|
||||||
const page = params.cursor ? Number(params.cursor) : 1;
|
|
||||||
const query = params.query ?? "";
|
|
||||||
const listUrl = `${LIST_URL}?diet=ALL&page=${page}&query=${encodeURIComponent(query)}`;
|
|
||||||
|
|
||||||
let response: Response;
|
|
||||||
try {
|
|
||||||
response = await fetch(listUrl);
|
|
||||||
} catch (cause) {
|
|
||||||
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error listing recipes (${listUrl})`, {
|
|
||||||
cause,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new RecipeSourceFetchError(
|
|
||||||
SOURCE_KEY,
|
|
||||||
`mangerbouger.fr responded ${response.status} (${listUrl})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const html = await response.text();
|
|
||||||
|
|
||||||
const data = extractNextData<MangerBougerListPageData>(html);
|
|
||||||
const state = data?.props?.initialState?.recipes;
|
|
||||||
|
|
||||||
const items: RecipeSourceListItem[] = (state?.list ?? [])
|
|
||||||
.filter((entry): entry is MangerBougerListEntry & { slug: string; name: string } =>
|
|
||||||
Boolean(entry.slug && entry.name),
|
|
||||||
)
|
|
||||||
.map((entry) => ({
|
|
||||||
externalId: detailUrl(entry.slug),
|
|
||||||
title: entry.name,
|
|
||||||
picture: entry.image ?? null,
|
|
||||||
url: detailUrl(entry.slug),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { items, nextCursor: state?.hasMorePages ? String(page + 1) : null };
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is (already keyed "mangerBouger" by whichever branch
|
|
||||||
// above threw it) — this adapter's only caller (`sources.service.ts`)
|
|
||||||
// already handles/logs failures centrally; this method just isn't
|
|
||||||
// allowed a bare `async` body without a try/catch per the repo's
|
|
||||||
// convention. Same reasoning as `marmiton.ts`/`750g.ts`.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// `externalId` est directement l'URL canonique de la recette sur
|
|
||||||
// mangerbouger.fr (renvoyée telle quelle par `list()` ci-dessus) — même
|
|
||||||
// convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
|
|
||||||
// délègue entièrement : la réparation du JSON-LD (voir
|
|
||||||
// `patchRecipeJsonLd`) n'a lieu qu'à l'étape `parse()`, pas ici.
|
|
||||||
async fetchDetail(externalId: string): Promise<{ html: string; url: string }> {
|
|
||||||
try {
|
|
||||||
return await jsonLdRecipeAdapter.fetchDetail(externalId);
|
|
||||||
} catch (err) {
|
|
||||||
// Pas un simple re-throw : `rekeySourceError` est le traitement utile
|
|
||||||
// que ce point d'appel doit faire de l'erreur (relabelliser sa
|
|
||||||
// `sourceKey`), conformément à la convention await/try-catch du repo.
|
|
||||||
throw rekeySourceError(err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
parse(raw: { html: string; url: string }): ParsedRecipe {
|
|
||||||
try {
|
|
||||||
const patchedHtml = patchRecipeJsonLd(raw.html);
|
|
||||||
return jsonLdRecipeAdapter.parse({ html: patchedHtml, url: raw.url });
|
|
||||||
} catch (err) {
|
|
||||||
throw rekeySourceError(err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
import type {
|
|
||||||
ParsedRecipe,
|
|
||||||
RecipeSourceAdapter,
|
|
||||||
RecipeSourceListItem,
|
|
||||||
RecipeSourceListParams,
|
|
||||||
RecipeSourceListResult,
|
|
||||||
} from "../lib/recipe-sources/recipe-source-adapter.js";
|
|
||||||
import {
|
|
||||||
RecipeSourceFetchError,
|
|
||||||
RecipeSourceParseError,
|
|
||||||
} from "../lib/recipe-sources/recipe-source-errors.js";
|
|
||||||
import { extractJsonLdBlocks, jsonLdRecipeAdapter } from "./json-ld-recipe.js";
|
|
||||||
|
|
||||||
const SOURCE_KEY = "marmiton";
|
|
||||||
const SEARCH_URL = "https://www.marmiton.org/recettes/recherche.aspx";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One `ListItem` inside the schema.org `ItemList` marmiton.org embeds as
|
|
||||||
* JSON-LD on its search-results pages — the subset this adapter reads. Also
|
|
||||||
* what a search whose term happens to match a known ingredient (e.g.
|
|
||||||
* `aqt=poulet`) actually returns: marmiton.org silently serves its
|
|
||||||
* ingredient-index page instead of a "search results" page for those terms,
|
|
||||||
* but that page embeds the exact same `ItemList` shape, so `list()` doesn't
|
|
||||||
* need to tell the two apart.
|
|
||||||
*/
|
|
||||||
interface MarmitonListItem {
|
|
||||||
"@type"?: string;
|
|
||||||
url?: string;
|
|
||||||
name?: string;
|
|
||||||
image?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The subset of a schema.org `ItemList` this adapter reads off marmiton.org's search-results page. */
|
|
||||||
interface MarmitonItemList {
|
|
||||||
"@type"?: string;
|
|
||||||
"@graph"?: unknown[];
|
|
||||||
itemListElement?: MarmitonListItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds the first `ItemList` node within one parsed JSON-LD block — mirrors
|
|
||||||
* `findRecipeNode`'s traversal in json-ld-recipe.ts (array of mixed-type
|
|
||||||
* nodes, `@graph` wrapper) but looks for the results listing marmiton.org's
|
|
||||||
* search page embeds instead of a `Recipe`.
|
|
||||||
*/
|
|
||||||
function findItemListNode(node: unknown): MarmitonItemList | null {
|
|
||||||
if (node === null || typeof node !== "object") return null;
|
|
||||||
if (Array.isArray(node)) {
|
|
||||||
for (const item of node) {
|
|
||||||
const found = findItemListNode(item);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const obj = node as MarmitonItemList;
|
|
||||||
if (obj["@type"] === "ItemList") return obj;
|
|
||||||
if (Array.isArray(obj["@graph"])) return findItemListNode(obj["@graph"]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
|
|
||||||
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having
|
|
||||||
* come from this adapter instead (`sourceKey` `"marmiton"`). `fetchDetail`/
|
|
||||||
* `parse` below are thin wrappers around the generic adapter's own methods
|
|
||||||
* (see this module's doc comment) — but a caller catching `RecipeSourceError`
|
|
||||||
* and reading `.sourceKey` to attribute a failure to a specific `Source`
|
|
||||||
* should see "marmiton", the source it actually asked about, not the
|
|
||||||
* internal implementation detail this adapter happens to be built on.
|
|
||||||
* Anything else (a bug, an unexpected throw) is passed through unchanged —
|
|
||||||
* only the vocabulary this module documents gets relabeled.
|
|
||||||
*/
|
|
||||||
function rekeySourceError(err: unknown): unknown {
|
|
||||||
if (err instanceof RecipeSourceFetchError) {
|
|
||||||
return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause });
|
|
||||||
}
|
|
||||||
if (err instanceof RecipeSourceParseError) {
|
|
||||||
return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause });
|
|
||||||
}
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* marmiton.org — France's largest recipe site. Unofficial (`official:
|
|
||||||
* false`): there's no published API, this adapter fetches ordinary pages and
|
|
||||||
* reads the schema.org structured data marmiton.org embeds for search
|
|
||||||
* engines, same as {@link jsonLdRecipeAdapter} it's built on. It's the first
|
|
||||||
* concrete, per-site adapter that generic adapter's own doc comment
|
|
||||||
* anticipated ("a concrete adapter for a specific site would use it
|
|
||||||
* internally") — `fetchDetail`/`parse` below just delegate straight to it,
|
|
||||||
* since a marmiton.org recipe page's JSON-LD is a plain schema.org `Recipe`
|
|
||||||
* with nothing site-specific to handle. The only real Marmiton-specific
|
|
||||||
* logic is `list()`: `jsonLdRecipeAdapter` has no catalog of its own to
|
|
||||||
* browse, but marmiton.org's search-results page embeds a browsable
|
|
||||||
* `ItemList` this adapter reads directly (see {@link findItemListNode}).
|
|
||||||
*/
|
|
||||||
export const marmitonAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
|
|
||||||
key: SOURCE_KEY,
|
|
||||||
name: "Marmiton",
|
|
||||||
official: false,
|
|
||||||
// Un chemin stable (jamais un nom de fichier avec un hash de build, comme
|
|
||||||
// les icônes servies depuis statics.marmiton.fr) — marmiton.org sert son
|
|
||||||
// favicon à cette adresse indépendamment de tout déploiement.
|
|
||||||
iconUrl: "https://www.marmiton.org/favicon.ico",
|
|
||||||
// Le contenu de Marmiton (noms, ingrédients, instructions) est en
|
|
||||||
// français — détermine contre quel modèle/locale d'étiquettes
|
|
||||||
// d'ingrédients translateRecipe (recipe-translation.ts) résout les
|
|
||||||
// recettes de cette source lors d'une prévisualisation/d'un import.
|
|
||||||
locale: "fr",
|
|
||||||
|
|
||||||
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
|
||||||
try {
|
|
||||||
// Un curseur opaque qui encode simplement le numéro de page suivant —
|
|
||||||
// marmiton.org pagine sa recherche via `&page=N` (page 1 implicite
|
|
||||||
// quand le paramètre est absent), pas de token dédié à faire
|
|
||||||
// transiter.
|
|
||||||
const page = params.cursor ? Number(params.cursor) : 1;
|
|
||||||
const query = params.query ?? "";
|
|
||||||
const searchUrl = `${SEARCH_URL}?aqt=${encodeURIComponent(query)}${
|
|
||||||
page > 1 ? `&page=${page}` : ""
|
|
||||||
}`;
|
|
||||||
|
|
||||||
let response: Response;
|
|
||||||
try {
|
|
||||||
response = await fetch(searchUrl);
|
|
||||||
} catch (cause) {
|
|
||||||
throw new RecipeSourceFetchError(
|
|
||||||
SOURCE_KEY,
|
|
||||||
`Network error searching Marmiton (${searchUrl})`,
|
|
||||||
{ cause },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// marmiton.org répond 404 dès que `page` dépasse la dernière page de
|
|
||||||
// résultats pour cette recherche — pas un vrai échec, juste "il n'y a
|
|
||||||
// plus rien" : son `ItemList` ne porte aucun total fiable (son
|
|
||||||
// `numberOfItems` vaut toujours la taille de la page courante, jamais
|
|
||||||
// le nombre total de résultats) pour le détecter à l'avance autrement
|
|
||||||
// qu'en demandant la page suivante et en constatant qu'elle est vide.
|
|
||||||
if (response.status === 404) {
|
|
||||||
return { items: [], nextCursor: null };
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new RecipeSourceFetchError(
|
|
||||||
SOURCE_KEY,
|
|
||||||
`Marmiton search responded ${response.status} (${searchUrl})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const html = await response.text();
|
|
||||||
|
|
||||||
let itemList: MarmitonItemList | null = null;
|
|
||||||
for (const block of extractJsonLdBlocks(html)) {
|
|
||||||
itemList = findItemListNode(block);
|
|
||||||
if (itemList) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const items: RecipeSourceListItem[] = (itemList?.itemListElement ?? [])
|
|
||||||
.filter((entry): entry is MarmitonListItem & { url: string; name: string } =>
|
|
||||||
Boolean(entry.url && entry.name),
|
|
||||||
)
|
|
||||||
.map((entry) => ({
|
|
||||||
externalId: entry.url,
|
|
||||||
title: entry.name,
|
|
||||||
picture: entry.image ?? null,
|
|
||||||
url: entry.url,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
// Voir le commentaire ci-dessus sur la réponse 404 : une page vide
|
|
||||||
// est elle-même le signal de fin, donc on ne propose une page
|
|
||||||
// suivante que si celle-ci en a retourné au moins un résultat.
|
|
||||||
nextCursor: items.length > 0 ? String(page + 1) : null,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is (already keyed "marmiton" by whichever branch above
|
|
||||||
// threw it) — this adapter's only caller (`sources.service.ts`)
|
|
||||||
// already handles/logs failures centrally; this method just isn't
|
|
||||||
// allowed a bare `async` body without a try/catch per the repo's
|
|
||||||
// convention. Same reasoning as `json-ld-recipe.ts`/`the-meal-db.ts`.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// `externalId` est directement l'URL canonique de la recette sur
|
|
||||||
// marmiton.org (renvoyée telle quelle par `list()` ci-dessus) — même
|
|
||||||
// convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
|
|
||||||
// délègue entièrement (voir le commentaire du module).
|
|
||||||
async fetchDetail(externalId: string): Promise<{ html: string; url: string }> {
|
|
||||||
try {
|
|
||||||
return await jsonLdRecipeAdapter.fetchDetail(externalId);
|
|
||||||
} catch (err) {
|
|
||||||
// Pas un simple re-throw : `rekeySourceError` est le traitement utile
|
|
||||||
// que ce point d'appel doit faire de l'erreur (relabelliser sa
|
|
||||||
// `sourceKey`), conformément à la convention await/try-catch du repo.
|
|
||||||
throw rekeySourceError(err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
parse(raw: { html: string; url: string }): ParsedRecipe {
|
|
||||||
try {
|
|
||||||
return jsonLdRecipeAdapter.parse(raw);
|
|
||||||
} catch (err) {
|
|
||||||
throw rekeySourceError(err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
50
apps/api/src/types/node-nlp.d.ts
vendored
Normal file
50
apps/api/src/types/node-nlp.d.ts
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* Minimal ambient typing for `node-nlp` (no official/DefinitelyTyped types
|
||||||
|
* exist for it) — declares only the `NlpManager` surface
|
||||||
|
* `tech-step-matcher.ts` actually calls, verified against the real
|
||||||
|
* package (v4.27.0) rather than the library's full documented API, which
|
||||||
|
* this repo doesn't use the rest of.
|
||||||
|
*/
|
||||||
|
declare module "node-nlp" {
|
||||||
|
/** Constructor options this repo passes — `NlpManager` accepts more, only what's used here is typed. */
|
||||||
|
export interface NlpManagerOptions {
|
||||||
|
languages?: string[];
|
||||||
|
forceNER?: boolean;
|
||||||
|
nlu?: { log?: boolean };
|
||||||
|
ner?: { threshold?: number };
|
||||||
|
/** Defaults to `true` — persists the trained model to `modelFileName` (default `model.nlp`, in `process.cwd()`). See `tech-step-matcher.ts`'s own constructor comment for why this repo always sets it `false`. */
|
||||||
|
autoSave?: boolean;
|
||||||
|
/** Defaults to `true` — loads from `modelFileName` instead of training fresh if that file already exists. Always `false` here, same reasoning as `autoSave`. */
|
||||||
|
autoLoad?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entity `NlpManager.process`'s result reports — see `tech-step-matcher.ts`'s own `NerEntity` for the subset this repo reads. */
|
||||||
|
export interface NlpEntity {
|
||||||
|
entity: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
type: string;
|
||||||
|
accuracy?: number;
|
||||||
|
sourceText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `NlpManager.process`'s result — trimmed to the fields this repo reads (the real object carries many more). */
|
||||||
|
export interface NlpProcessResult {
|
||||||
|
intent: string;
|
||||||
|
score: number;
|
||||||
|
entities: NlpEntity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NlpManager {
|
||||||
|
public constructor(options?: NlpManagerOptions);
|
||||||
|
public addNamedEntityText(
|
||||||
|
entityName: string,
|
||||||
|
optionName: string,
|
||||||
|
languages: string[],
|
||||||
|
texts: string[],
|
||||||
|
): void;
|
||||||
|
public addDocument(locale: string, utterance: string, intent: string): void;
|
||||||
|
public train(): Promise<void>;
|
||||||
|
public process(locale: string, text: string): Promise<NlpProcessResult>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { techStepClassifier } from "../src/lib/recipe-matching/tech-step-matcher.js";
|
|
||||||
import { resetDatabase } from "./reset-db.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mocha root hook plugin (see `.mocharc.json`'s `require`) — runs once
|
|
||||||
* before every test file's own suites, regardless of load order.
|
|
||||||
*
|
|
||||||
* Warms up `techStepClassifier` here — resolving the `TechStep.key -> id`
|
|
||||||
* lookup from the DB (see `TechStepClassifierService._loadTechStepIds`) —
|
|
||||||
* instead of leaving it to happen lazily on whichever test file Mocha
|
|
||||||
* happens to load first, same as `server.ts` does before the real server
|
|
||||||
* ever accepts traffic. Fast by itself (one DB query, one HTTP call to
|
|
||||||
* `services/tech-step-intent-service`): that service now trains itself
|
|
||||||
* entirely at its own process startup (see its own README), so unlike
|
|
||||||
* before this migration, nothing here waits on a slow training pass — CI's
|
|
||||||
* own "wait for `/health`" step (`.github/workflows/ci.yml`) is what
|
|
||||||
* ensures that service is already fully trained before `pnpm --filter api
|
|
||||||
* test` even starts.
|
|
||||||
*
|
|
||||||
* `resetDatabase()` runs first, deliberately: id resolution needs
|
|
||||||
* `TechStep` rows, and a freshly-migrated (never-seeded) test database has
|
|
||||||
* none yet. Every per-test `beforeEach` in this suite already calls
|
|
||||||
* `resetDatabase()` again before its own test, which is a no-op
|
|
||||||
* duplication of effort but not a correctness problem: `TRUNCATE ...
|
|
||||||
* RESTART IDENTITY` plus deterministic re-seeding (`seedReferenceData`)
|
|
||||||
* assigns the exact same ids every time, so the `uid -> id` map memoized
|
|
||||||
* here from this first reset stays valid for every reset after it.
|
|
||||||
*/
|
|
||||||
export const mochaHooks = {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: Mocha's root hook `this` (a Context with `.timeout()`) isn't typed without @types/mocha (not a dependency here) — same untyped-`this` shape already used in tech-step-worker.routes.test.ts.
|
|
||||||
async beforeAll(this: any): Promise<void> {
|
|
||||||
// A little more generous than Mocha's normal 10s per-test default
|
|
||||||
// (`.mocharc.json`) purely for a slower/contended CI runner's first
|
|
||||||
// network round-trip to `services/tech-step-intent-service` — not
|
|
||||||
// because anything here waits on training anymore.
|
|
||||||
this.timeout(30000);
|
|
||||||
await resetDatabase();
|
|
||||||
await techStepClassifier.warmUp();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,306 +0,0 @@
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
|
||||||
import { expect } from "chai";
|
|
||||||
import request from "supertest";
|
|
||||||
import { createApp } from "../../src/app.js";
|
|
||||||
import { env } from "../../src/config/env.js";
|
|
||||||
import { prisma } from "../../src/db/prisma.js";
|
|
||||||
import { resetDatabase } from "../../test-support/reset-db.js";
|
|
||||||
|
|
||||||
const SECRET_HEADER = "X-Internal-Worker-Secret";
|
|
||||||
|
|
||||||
/** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — mirrors `recipe.test.ts`'s own `techStepId` helper. */
|
|
||||||
async function techStepId(key: string): Promise<number> {
|
|
||||||
const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } });
|
|
||||||
return techStep.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A minimal author + recipe + step fixture — these routes have no notion of a session/viewer, so nothing here needs to go through `/auth/signup` the way `recipe.test.ts`'s fixtures do. */
|
|
||||||
async function createRecipeWithStep(
|
|
||||||
description = "Faire mijoter la sauce.",
|
|
||||||
): Promise<{ stepId: number; recipeId: number }> {
|
|
||||||
const author = await prisma.userProfile.create({
|
|
||||||
data: {
|
|
||||||
firstName: "Test",
|
|
||||||
lastName: "Author",
|
|
||||||
email: `${crypto.randomUUID()}@example.test`,
|
|
||||||
passwordHash: "not-a-real-hash",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const recipe = await prisma.recipe.create({
|
|
||||||
data: {
|
|
||||||
name: "Recette",
|
|
||||||
authorId: author.id,
|
|
||||||
portions: 4,
|
|
||||||
steps: { create: [{ description, order: 0 }] },
|
|
||||||
},
|
|
||||||
include: { steps: true },
|
|
||||||
});
|
|
||||||
const step = recipe.steps[0];
|
|
||||||
if (!step) throw new Error("expected the fixture recipe to have one step");
|
|
||||||
return { stepId: step.id, recipeId: recipe.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Internal tech-step worker routes", () => {
|
|
||||||
const app = createApp();
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("requireInternalWorker", () => {
|
|
||||||
it("rejects a request with no secret header with 401 NOT_AUTHENTICATED", async () => {
|
|
||||||
const res = await request(app).get("/internal/tech-steps/audit-batch");
|
|
||||||
|
|
||||||
expect(res.status).to.equal(401);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a request with the wrong secret with 401 NOT_AUTHENTICATED", async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.get("/internal/tech-steps/audit-batch")
|
|
||||||
.set(SECRET_HEADER, "definitely-not-the-right-secret");
|
|
||||||
|
|
||||||
expect(res.status).to.equal(401);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects even the correct secret with 401 NOT_AUTHENTICATED on a plain user-facing route (no bypass of requireAuth)", async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.get("/recipes")
|
|
||||||
.query({ tab: "publique" })
|
|
||||||
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET ?? "irrelevant-unset-in-this-env");
|
|
||||||
|
|
||||||
expect(res.status).to.equal(401);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Every test below needs a real configured secret to exercise the success
|
|
||||||
// path — skipped (not failed) in an environment that hasn't set one, same
|
|
||||||
// "optional, but the surface fails closed without it" posture
|
|
||||||
// `INTERNAL_WORKER_SECRET` itself has (see config/env.ts). Both this
|
|
||||||
// repo's `.env.test.example` and `.github/workflows/ci.yml` set one, so
|
|
||||||
// this only actually skips in an environment that deliberately diverges
|
|
||||||
// from both.
|
|
||||||
describe("with a configured secret", () => {
|
|
||||||
before(function skipWithoutConfiguredSecret() {
|
|
||||||
if (env.INTERNAL_WORKER_SECRET === undefined) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed without @types/mocha (not a dependency here) — same untyped-`this` shape a plain JS mocha callback would have.
|
|
||||||
(this as any).skip();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function withSecret(req: request.Test): request.Test {
|
|
||||||
return req.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("GET /internal/tech-steps/audit-batch", () => {
|
|
||||||
// A true low-confidence positive case can't be asserted here without
|
|
||||||
// a live trained classifier to verify the exact sentence against
|
|
||||||
// first — same limitation `tech-step-eval-dataset.ts` documents for
|
|
||||||
// the same reason (no local Postgres was reachable in the session
|
|
||||||
// that introduced this file). This test instead covers the
|
|
||||||
// deterministic negative: a step the classifier confidently resolves
|
|
||||||
// (proven by `tech-step-matcher.test.ts`'s own identical-sentence
|
|
||||||
// case) must produce zero audit entries — nothing here should ever
|
|
||||||
// flag a confident match as worth a second opinion.
|
|
||||||
it("finds nothing to audit in a step the classifier confidently resolves", async () => {
|
|
||||||
await createRecipeWithStep("Faire mijoter à feu doux");
|
|
||||||
|
|
||||||
const res = await withSecret(
|
|
||||||
request(app).get("/internal/tech-steps/audit-batch").query({ locale: "fr" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finds nothing to audit in a step naming no technique at all", async () => {
|
|
||||||
await createRecipeWithStep("Ranger les couverts dans le tiroir");
|
|
||||||
|
|
||||||
const res = await withSecret(
|
|
||||||
request(app).get("/internal/tech-steps/audit-batch").query({ locale: "fr" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a non-positive limit with 400 VALIDATION_ERROR", async () => {
|
|
||||||
const res = await withSecret(
|
|
||||||
request(app).get("/internal/tech-steps/audit-batch").query({ limit: 0 }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /internal/tech-steps/pending-corrections", () => {
|
|
||||||
it("returns unconsumed corrections, oldest first, excluding already-consumed ones", async () => {
|
|
||||||
const { stepId, recipeId } = await createRecipeWithStep();
|
|
||||||
const simmerId = await techStepId("simmer");
|
|
||||||
const author = await prisma.recipe
|
|
||||||
.findUniqueOrThrow({ where: { id: recipeId } })
|
|
||||||
.then((recipe) => recipe.authorId);
|
|
||||||
|
|
||||||
const older = await prisma.stepTechStepCorrection.create({
|
|
||||||
data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
|
|
||||||
});
|
|
||||||
const consumed = await prisma.stepTechStepCorrection.create({
|
|
||||||
data: {
|
|
||||||
stepId,
|
|
||||||
correctorId: author,
|
|
||||||
start: 0,
|
|
||||||
end: 5,
|
|
||||||
correctedTechStepId: simmerId,
|
|
||||||
consumedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const newer = await prisma.stepTechStepCorrection.create({
|
|
||||||
data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await withSecret(request(app).get("/internal/tech-steps/pending-corrections"));
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
const ids = (res.body as Array<{ id: number }>).map((entry) => entry.id);
|
|
||||||
expect(ids).to.deep.equal([older.id, newer.id]);
|
|
||||||
expect(ids).to.not.include(consumed.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects ?limit=", async () => {
|
|
||||||
const { stepId, recipeId } = await createRecipeWithStep();
|
|
||||||
const simmerId = await techStepId("simmer");
|
|
||||||
const author = await prisma.recipe
|
|
||||||
.findUniqueOrThrow({ where: { id: recipeId } })
|
|
||||||
.then((recipe) => recipe.authorId);
|
|
||||||
await prisma.stepTechStepCorrection.createMany({
|
|
||||||
data: [
|
|
||||||
{ stepId, correctorId: author, start: 0, end: 5, correctedTechStepId: simmerId },
|
|
||||||
{ stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await withSecret(
|
|
||||||
request(app).get("/internal/tech-steps/pending-corrections").query({ limit: 1 }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body).to.have.length(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("POST /internal/tech-steps/training-suggestions", () => {
|
|
||||||
it("creates a suggestion and marks its source correction consumed", async () => {
|
|
||||||
const { stepId, recipeId } = await createRecipeWithStep();
|
|
||||||
const simmerId = await techStepId("simmer");
|
|
||||||
const author = await prisma.recipe
|
|
||||||
.findUniqueOrThrow({ where: { id: recipeId } })
|
|
||||||
.then((recipe) => recipe.authorId);
|
|
||||||
const correction = await prisma.stepTechStepCorrection.create({
|
|
||||||
data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await withSecret(
|
|
||||||
request(app)
|
|
||||||
.post("/internal/tech-steps/training-suggestions")
|
|
||||||
.send({
|
|
||||||
suggestions: [
|
|
||||||
{
|
|
||||||
techStepKey: "simmer",
|
|
||||||
locale: "fr",
|
|
||||||
suggestedSynonyms: ["frémissonner"],
|
|
||||||
suggestedUtterances: ["laisser frémissonner à feu très doux"],
|
|
||||||
sourceType: "correction",
|
|
||||||
sourceCorrectionId: correction.id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
expect(res.body).to.deep.equal({ created: 1 });
|
|
||||||
|
|
||||||
const suggestions = await prisma.techStepTrainingSuggestion.findMany({
|
|
||||||
where: { techStepId: simmerId },
|
|
||||||
});
|
|
||||||
expect(suggestions).to.have.length(1);
|
|
||||||
expect(suggestions[0]?.sourceCorrectionId).to.equal(correction.id);
|
|
||||||
expect(suggestions[0]?.status).to.equal("pending");
|
|
||||||
|
|
||||||
const updatedCorrection = await prisma.stepTechStepCorrection.findUniqueOrThrow({
|
|
||||||
where: { id: correction.id },
|
|
||||||
});
|
|
||||||
expect(updatedCorrection.consumedAt).to.not.equal(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts an llm_audit suggestion with no sourceCorrectionId", async () => {
|
|
||||||
const res = await withSecret(
|
|
||||||
request(app)
|
|
||||||
.post("/internal/tech-steps/training-suggestions")
|
|
||||||
.send({
|
|
||||||
suggestions: [
|
|
||||||
{
|
|
||||||
techStepKey: "boil",
|
|
||||||
locale: "fr",
|
|
||||||
suggestedSynonyms: ["bouillonner"],
|
|
||||||
suggestedUtterances: [],
|
|
||||||
sourceType: "llm_audit",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
expect(res.body).to.deep.equal({ created: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects sourceType 'correction' with no sourceCorrectionId with 400 VALIDATION_ERROR", async () => {
|
|
||||||
const res = await withSecret(
|
|
||||||
request(app)
|
|
||||||
.post("/internal/tech-steps/training-suggestions")
|
|
||||||
.send({
|
|
||||||
suggestions: [
|
|
||||||
{
|
|
||||||
techStepKey: "boil",
|
|
||||||
locale: "fr",
|
|
||||||
suggestedSynonyms: ["bouillonner"],
|
|
||||||
suggestedUtterances: [],
|
|
||||||
sourceType: "correction",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an unknown techStepKey with 404 TECH_STEP_NOT_FOUND", async () => {
|
|
||||||
const res = await withSecret(
|
|
||||||
request(app)
|
|
||||||
.post("/internal/tech-steps/training-suggestions")
|
|
||||||
.send({
|
|
||||||
suggestions: [
|
|
||||||
{
|
|
||||||
techStepKey: "not-a-real-tech-step",
|
|
||||||
locale: "fr",
|
|
||||||
suggestedSynonyms: [],
|
|
||||||
suggestedUtterances: [],
|
|
||||||
sourceType: "llm_audit",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).to.equal(404);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { INGREDIENT_LABEL_SYNONYMS_EN, INGREDIENT_LABEL_SYNONYMS_FR } from "@batch-cooking/shared";
|
import { INGREDIENT_LABEL_SYNONYMS_EN } from "@batch-cooking/shared";
|
||||||
import { expect } from "chai";
|
import { 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,
|
||||||
|
|
@ -91,48 +90,6 @@ 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", () => {
|
||||||
|
|
@ -160,14 +117,10 @@ describe("ingredient-matcher", () => {
|
||||||
expect(matchUnit("TBSP", catalog)).to.equal(tablespoon.unitId);
|
expect(matchUnit("TBSP", catalog)).to.equal(tablespoon.unitId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores trailing text after the unit word", () => {
|
it("only looks at the first word — ignores trailing text", () => {
|
||||||
expect(matchUnit("cup flour", catalog)).to.equal(cup.unitId);
|
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);
|
||||||
|
|
@ -184,35 +137,6 @@ 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", () => {
|
||||||
|
|
@ -267,100 +191,6 @@ describe("ingredient-matcher", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("findIngredientMentions", () => {
|
|
||||||
const butter: IngredientMatchEntry = { ingredientId: 1, label: "Butter" };
|
|
||||||
const flour: IngredientMatchEntry = { ingredientId: 2, label: "Flour" };
|
|
||||||
const egg: IngredientMatchEntry = { ingredientId: 3, label: "Egg" };
|
|
||||||
const catalog = [butter, flour, egg];
|
|
||||||
const gram: UnitMatchEntry = { unitId: 1, synonyms: ["g", "gram", "grams"] };
|
|
||||||
const unitCatalog = [gram];
|
|
||||||
|
|
||||||
it("finds a single mention with no quantity or unit", () => {
|
|
||||||
const text = "melt the butter";
|
|
||||||
const mentions = findIngredientMentions(text, catalog, unitCatalog);
|
|
||||||
expect(mentions).to.have.length(1);
|
|
||||||
const [mention] = mentions;
|
|
||||||
expect(mention?.ingredientId).to.equal(butter.ingredientId);
|
|
||||||
expect(text.slice(mention?.start, mention?.end)).to.equal("butter");
|
|
||||||
expect(mention?.quantity).to.equal(null);
|
|
||||||
expect(mention?.unitId).to.equal(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves a quantity and unit glued directly to the ingredient ("200g butter")', () => {
|
|
||||||
const text = "add 200g butter";
|
|
||||||
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
|
|
||||||
expect(mention?.ingredientId).to.equal(butter.ingredientId);
|
|
||||||
expect(mention?.quantity).to.equal(200);
|
|
||||||
expect(mention?.unitId).to.equal(gram.unitId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finds several mentions in reading order, non-overlapping", () => {
|
|
||||||
const text = "melt the butter then add the flour and an egg";
|
|
||||||
const mentions = findIngredientMentions(text, catalog, unitCatalog);
|
|
||||||
expect(mentions.map((mention) => mention.ingredientId)).to.deep.equal([
|
|
||||||
butter.ingredientId,
|
|
||||||
flour.ingredientId,
|
|
||||||
egg.ingredientId,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is case- and accent-insensitive", () => {
|
|
||||||
const text = "MELT THE BUTTER";
|
|
||||||
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
|
|
||||||
expect(mention?.ingredientId).to.equal(butter.ingredientId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores an unrelated number earlier in the text (e.g. an oven temperature)", () => {
|
|
||||||
const text = "preheat to 180 degrees then add the egg";
|
|
||||||
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
|
|
||||||
expect(mention?.ingredientId).to.equal(egg.ingredientId);
|
|
||||||
expect(mention?.quantity).to.equal(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty array when nothing in the catalog is mentioned", () => {
|
|
||||||
expect(findIngredientMentions("stir well", catalog, unitCatalog)).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty array for empty text", () => {
|
|
||||||
expect(findIngredientMentions("", catalog, unitCatalog)).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("locale: fr", () => {
|
|
||||||
const beurre: IngredientMatchEntry = { ingredientId: 10, label: "Beurre" };
|
|
||||||
const farine: IngredientMatchEntry = { ingredientId: 11, label: "Farine" };
|
|
||||||
const frCatalog = [beurre, farine];
|
|
||||||
const gramme: UnitMatchEntry = { unitId: 40, synonyms: ["g", "gr", "gramme", "grammes"] };
|
|
||||||
const cuillereASoupe: UnitMatchEntry = {
|
|
||||||
unitId: 41,
|
|
||||||
synonyms: ["cuillère à soupe", "cuillères à soupe", "càs"],
|
|
||||||
};
|
|
||||||
const frUnitCatalog = [gramme, cuillereASoupe];
|
|
||||||
|
|
||||||
it("resolves a quantity and unit before the ingredient, connected by 'de'", () => {
|
|
||||||
const text = "faire fondre 50g de beurre";
|
|
||||||
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
|
|
||||||
expect(mention?.ingredientId).to.equal(beurre.ingredientId);
|
|
||||||
expect(mention?.quantity).to.equal(50);
|
|
||||||
expect(mention?.unitId).to.equal(gramme.unitId);
|
|
||||||
expect(text.slice(mention?.start, mention?.end)).to.equal("beurre");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves a multi-word unit connected by "d\'"', () => {
|
|
||||||
const text = "ajouter 2 cuillères à soupe de farine";
|
|
||||||
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
|
|
||||||
expect(mention?.ingredientId).to.equal(farine.ingredientId);
|
|
||||||
expect(mention?.quantity).to.equal(2);
|
|
||||||
expect(mention?.unitId).to.equal(cuillereASoupe.unitId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is accent-insensitive", () => {
|
|
||||||
const text = "FAIRE FONDRE LE BEURRE";
|
|
||||||
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
|
|
||||||
expect(mention?.ingredientId).to.equal(beurre.ingredientId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("loadIngredientCatalog / loadUnitCatalog", () => {
|
describe("loadIngredientCatalog / loadUnitCatalog", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await resetDatabase();
|
await resetDatabase();
|
||||||
|
|
@ -409,51 +239,5 @@ 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,9 +38,8 @@ 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 (`services/tech-step-intent-service`'s
|
// the real training corpus (`tech-step-training-data.ts`) against a real
|
||||||
// `training_data.py`) against a real `TechStep` catalog rather than
|
// `TechStep` catalog rather than synthetic fixtures — same posture
|
||||||
// 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", () => {
|
||||||
|
|
@ -454,47 +453,7 @@ describe("recipe-translation", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves a real Ingredient id from the seeded French catalog, tolerating a regular French plural", async () => {
|
it("leaves ingredients untouched (no quantity extraction either) for a non-English locale — no matching data exists yet, and the DB isn't even queried for it", async () => {
|
||||||
const 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: [
|
||||||
|
|
@ -502,12 +461,12 @@ describe("recipe-translation", () => {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const translated = await translateRecipe(recipe, "de");
|
const translated = await translateRecipe(recipe, "fr");
|
||||||
|
|
||||||
expect(translated.ingredients).to.deep.equal([
|
expect(translated.ingredients).to.deep.equal([
|
||||||
{
|
{
|
||||||
rawText: "1 cup onions, chopped",
|
rawText: "1 cup onions, chopped",
|
||||||
quantity: 1,
|
quantity: null,
|
||||||
unit: null,
|
unit: null,
|
||||||
name: "onions",
|
name: "onions",
|
||||||
ingredientId: null,
|
ingredientId: null,
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { expect } from "chai";
|
|
||||||
import { prisma } from "../../src/db/prisma.js";
|
|
||||||
import {
|
|
||||||
MIN_OVERALL_F1,
|
|
||||||
runTechStepEvalSuite,
|
|
||||||
} from "../../src/lib/recipe-matching/tech-step-eval-runner.js";
|
|
||||||
import { resetDatabase } from "../../test-support/reset-db.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression gate for `TECH_STEP_TRAINING_DATA` — every change to that
|
|
||||||
* corpus (including a maintainer applying suggestions from
|
|
||||||
* `TechStepTrainingSuggestion`, see `scripts/retrain-tech-steps.ts`) must
|
|
||||||
* keep this suite green. Runs {@link runTechStepEvalSuite} (the real
|
|
||||||
* trained classifier against `tech-step-eval-dataset.ts`) and asserts the
|
|
||||||
* aggregate F1 doesn't fall below {@link MIN_OVERALL_F1} — see that
|
|
||||||
* constant's own doc comment (`tech-step-eval-runner.ts`) for the real run
|
|
||||||
* it was calibrated against.
|
|
||||||
*/
|
|
||||||
|
|
||||||
describe("tech-step-eval", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`scores at least ${MIN_OVERALL_F1} aggregate F1 against the labeled evaluation set`, async () => {
|
|
||||||
const { overall, byKey } = await runTechStepEvalSuite();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
overall.f1,
|
|
||||||
`aggregate F1 ${overall.f1.toFixed(3)} (precision ${overall.precision.toFixed(3)}, recall ${overall.recall.toFixed(3)}) fell below the ${MIN_OVERALL_F1} floor — per-technique breakdown: ${JSON.stringify(byKey)}`,
|
|
||||||
).to.be.at.least(MIN_OVERALL_F1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -118,16 +118,13 @@ 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
|
||||||
// (`services/tech-step-intent-service`'s `training_data.py`) and the
|
// (`tech-step-training-data.ts`) and the real seeded `TechStep`
|
||||||
// real seeded `TechStep` catalog, rather than synthetic injectable
|
// catalog, rather than synthetic injectable fixtures the old
|
||||||
// fixtures the old regex-based `matchTechStepSpans(description,
|
// regex-based `matchTechStepSpans(description, mappings)` allowed.
|
||||||
// mappings)` allowed. Every call round-trips over HTTP to a real,
|
// Training + node-nlp's own one-time per-language setup can take a
|
||||||
// locally running `services/tech-step-intent-service` (see that
|
// few seconds on the very first call in the whole suite (subsequent
|
||||||
// service's own README and `apps/api/.env.test`) — that service trains
|
// calls reuse the same trained model and are fast) — comfortably
|
||||||
// itself once at its own startup (`test-support/mocha-root-hooks.ts`'s
|
// inside this suite's default 10s timeout (.mocharc.json).
|
||||||
// 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;
|
||||||
|
|
@ -135,36 +132,18 @@ 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, pan, butter, onion, walnuts] =
|
const [simmer, cook, bake, preheat, melt, boil, chop] = await Promise.all([
|
||||||
await Promise.all([
|
prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } }),
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } }),
|
prisma.techStep.findFirstOrThrow({ where: { key: "cook" } }),
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "cook" } }),
|
prisma.techStep.findFirstOrThrow({ where: { key: "bake" } }),
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "bake" } }),
|
prisma.techStep.findFirstOrThrow({ where: { key: "preheat" } }),
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "preheat" } }),
|
prisma.techStep.findFirstOrThrow({ where: { key: "melt" } }),
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "melt" } }),
|
prisma.techStep.findFirstOrThrow({ where: { key: "boil" } }),
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "boil" } }),
|
prisma.techStep.findFirstOrThrow({ where: { key: "chop" } }),
|
||||||
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;
|
||||||
|
|
@ -172,10 +151,6 @@ 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 () => {
|
||||||
|
|
@ -277,15 +252,7 @@ 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");
|
||||||
});
|
});
|
||||||
|
|
@ -335,12 +302,6 @@ 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,
|
||||||
|
|
@ -348,15 +309,6 @@ 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(
|
||||||
|
|
@ -378,15 +330,6 @@ 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 }],
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
@ -395,33 +338,10 @@ 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.
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,341 +0,0 @@
|
||||||
import { expect } from "chai";
|
|
||||||
import {
|
|
||||||
RecipeSourceFetchError,
|
|
||||||
RecipeSourceParseError,
|
|
||||||
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
|
|
||||||
import { sevenFiftyGAdapter } from "../../src/sources/750g.js";
|
|
||||||
|
|
||||||
/** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as `json-ld-recipe.test.ts`'s `stubFetchHtml`. */
|
|
||||||
function stubFetchHtml(html: string, status = 200) {
|
|
||||||
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RECIPE_URL = "https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A raw (not `JSON.stringify`-escaped) JSON-LD `Recipe` payload, deliberately
|
|
||||||
* reproducing two real 750g.com bugs verified live on the recipe this test's
|
|
||||||
* URL/content is modeled after:
|
|
||||||
* - a literal, unescaped `\r\n` inside `recipeInstructions[0].text` (invalid
|
|
||||||
* JSON as-is — this is exactly what {@link sanitizeJsonLdBlocks} in the
|
|
||||||
* adapter under test has to repair before `JSON.parse` can succeed);
|
|
||||||
* - `Pr&eacute;parez` — a real "é" that went through 750g's own
|
|
||||||
* HTML-entity encoder twice (`decodeHtmlEntities` has to run twice to
|
|
||||||
* fully resolve it back to "é").
|
|
||||||
* Plus a plain `'` apostrophe entity in an ingredient line, the more
|
|
||||||
* common single-encoding case.
|
|
||||||
*/
|
|
||||||
const RAW_RECIPE_JSON_LD = `{
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "Recipe",
|
|
||||||
"name": "Poulet au vin jaune et aux morilles",
|
|
||||||
"description": "Une recette de f\\u00eate.",
|
|
||||||
"image": {"@type": "ImageObject", "url": "https://static.750g.com/images/poulet-vin-jaune.jpg"},
|
|
||||||
"recipeYield": "6 personnes",
|
|
||||||
"recipeIngredient": ["1 poulet fermier", "Sel 'fin'"],
|
|
||||||
"recipeInstructions": [
|
|
||||||
{"@type": "HowToStep", "text": "Pr&eacute;parez les morilles :\r\nFendez-les en deux."}
|
|
||||||
],
|
|
||||||
"url": "${RECIPE_URL}"
|
|
||||||
}`;
|
|
||||||
|
|
||||||
function htmlWithRawJsonLd(rawJson: string): string {
|
|
||||||
return `<!doctype html><html><head><script type="application/ld+json">${rawJson}</script></head><body></body></html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("sevenFiftyGAdapter", () => {
|
|
||||||
let originalFetch: typeof fetch;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
originalFetch = globalThis.fetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("declares itself as an unofficial, French-locale source with an icon", () => {
|
|
||||||
expect(sevenFiftyGAdapter.key).to.equal("750g");
|
|
||||||
expect(sevenFiftyGAdapter.name).to.equal("750g");
|
|
||||||
expect(sevenFiftyGAdapter.official).to.equal(false);
|
|
||||||
expect(sevenFiftyGAdapter.iconUrl).to.be.a("string");
|
|
||||||
expect(sevenFiftyGAdapter.locale).to.equal("fr");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("list", () => {
|
|
||||||
/**
|
|
||||||
* Models the real shape found live: a card's own `<img>` sits
|
|
||||||
* immediately before its `<a class="card-link">`, but the fragment also
|
|
||||||
* carries decorative images that belong to no card at all (verified
|
|
||||||
* live: 28 `<img>` tags against 23 real cards for one sample query) —
|
|
||||||
* an image search that isn't "nearest preceding, not naive same-index
|
|
||||||
* zip" would misattribute every card after the first stray image.
|
|
||||||
*/
|
|
||||||
const CARDS_HTML = `
|
|
||||||
<div class="grid">
|
|
||||||
<img src="https://static.750g.com/images/x/orphan-lead.jpg" class="decorative" />
|
|
||||||
<div class="card">
|
|
||||||
<img src="https://static.750g.com/images/x/tarte.jpg" alt="Tarte" />
|
|
||||||
<a href="https://www.750g.com/tarte-aux-pommes-r1.htm" class="card-link ">Tarte aux pommes</a>
|
|
||||||
</div>
|
|
||||||
<img src="https://static.750g.com/images/x/orphan-mid-1.jpg" class="decorative" />
|
|
||||||
<img src="https://static.750g.com/images/x/orphan-mid-2.jpg" class="decorative" />
|
|
||||||
<div class="card">
|
|
||||||
<img src="https://static.750g.com/images/x/gratin.jpg" alt="Gratin" />
|
|
||||||
<a href="https://www.750g.com/gratin-dauphinois-r2.htm" class="card-link ">Gratin dauphinois</a>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<a href="https://www.750g.com/pain-perdu-r3.htm" class="card-link ">Pain perdu</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
it("scrapes each card's title/url/image, matching each image to its nearest preceding link and ignoring orphan images", async () => {
|
|
||||||
stubFetchHtml(CARDS_HTML);
|
|
||||||
|
|
||||||
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
|
|
||||||
|
|
||||||
expect(result.items).to.deep.equal([
|
|
||||||
{
|
|
||||||
externalId: "https://www.750g.com/tarte-aux-pommes-r1.htm",
|
|
||||||
title: "Tarte aux pommes",
|
|
||||||
picture: "https://static.750g.com/images/x/tarte.jpg",
|
|
||||||
url: "https://www.750g.com/tarte-aux-pommes-r1.htm",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
externalId: "https://www.750g.com/gratin-dauphinois-r2.htm",
|
|
||||||
title: "Gratin dauphinois",
|
|
||||||
picture: "https://static.750g.com/images/x/gratin.jpg",
|
|
||||||
url: "https://www.750g.com/gratin-dauphinois-r2.htm",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
externalId: "https://www.750g.com/pain-perdu-r3.htm",
|
|
||||||
title: "Pain perdu",
|
|
||||||
picture: null,
|
|
||||||
url: "https://www.750g.com/pain-perdu-r3.htm",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("decodes HTML entities in a card's title", async () => {
|
|
||||||
stubFetchHtml(
|
|
||||||
`<a href="https://www.750g.com/tarte-r1.htm" class="card-link ">Tarte aux pommes 'reinettes'</a>`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
|
|
||||||
|
|
||||||
expect(result.items[0]?.title).to.equal("Tarte aux pommes 'reinettes'");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("always returns nextCursor: null — this search isn't really paginated (requesting a further page comes back empty)", async () => {
|
|
||||||
stubFetchHtml(CARDS_HTML);
|
|
||||||
|
|
||||||
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
|
|
||||||
|
|
||||||
expect(result.nextCursor).to.be.null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores params.cursor for a text search — always requests page=1, there's never a legitimate cursor for this (non-paginated) endpoint", async () => {
|
|
||||||
let requestedUrl: string | undefined;
|
|
||||||
globalThis.fetch = (async (url: string) => {
|
|
||||||
requestedUrl = url;
|
|
||||||
return new Response("", { status: 200 });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
await sevenFiftyGAdapter.list({ query: "tarte", cursor: "7" });
|
|
||||||
|
|
||||||
expect(requestedUrl).to.include("page=1");
|
|
||||||
expect(requestedUrl).not.to.include("page=7");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("URL-encodes the query", async () => {
|
|
||||||
let requestedUrl: string | undefined;
|
|
||||||
globalThis.fetch = (async (url: string) => {
|
|
||||||
requestedUrl = url;
|
|
||||||
return new Response("", { status: 200 });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
await sevenFiftyGAdapter.list({ query: "tarte aux pommes" });
|
|
||||||
|
|
||||||
expect(requestedUrl).to.include("query=tarte%20aux%20pommes");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("empty/omitted query (browsing with no filter)", () => {
|
|
||||||
it("reads 'dernières recettes' instead of the AI search — the search endpoint answers a blank query with nothing at all, which would otherwise make browsing with no filter always come back empty", async () => {
|
|
||||||
let requestedUrl: string | undefined;
|
|
||||||
globalThis.fetch = (async (url: string) => {
|
|
||||||
requestedUrl = url;
|
|
||||||
return new Response(CARDS_HTML, { status: 200 });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
const result = await sevenFiftyGAdapter.list({});
|
|
||||||
|
|
||||||
expect(requestedUrl).to.include("dernieres-recettes.htm");
|
|
||||||
expect(requestedUrl).not.to.include("genius/query");
|
|
||||||
expect(result.items).to.have.length(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("also browses for an explicitly empty query string, not just an omitted one", async () => {
|
|
||||||
stubFetchHtml(CARDS_HTML);
|
|
||||||
|
|
||||||
const result = await sevenFiftyGAdapter.list({ query: "" });
|
|
||||||
|
|
||||||
expect(result.items).to.have.length(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requests the given cursor's page", async () => {
|
|
||||||
let requestedUrl: string | undefined;
|
|
||||||
globalThis.fetch = (async (url: string) => {
|
|
||||||
requestedUrl = url;
|
|
||||||
return new Response(CARDS_HTML, { status: 200 });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
await sevenFiftyGAdapter.list({ cursor: "5" });
|
|
||||||
|
|
||||||
expect(requestedUrl).to.include("page=5");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers a next page when the page has cards, and none once a page comes back empty — this endpoint never 404s/redirects past its real end", async () => {
|
|
||||||
stubFetchHtml(CARDS_HTML);
|
|
||||||
const withItems = await sevenFiftyGAdapter.list({ cursor: "2" });
|
|
||||||
expect(withItems.nextCursor).to.equal("3");
|
|
||||||
|
|
||||||
stubFetchHtml("<html><body>Plus rien ici</body></html>");
|
|
||||||
const empty = await sevenFiftyGAdapter.list({ cursor: "50" });
|
|
||||||
expect(empty.nextCursor).to.be.null;
|
|
||||||
expect(empty.items).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
|
|
||||||
stubFetchHtml("", 500);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sevenFiftyGAdapter.list({ query: "x" });
|
|
||||||
expect.fail("expected list to throw");
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
|
||||||
expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
|
|
||||||
globalThis.fetch = (async () => {
|
|
||||||
throw new Error("network down");
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sevenFiftyGAdapter.list({ query: "x" });
|
|
||||||
expect.fail("expected list to throw");
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
|
||||||
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchDetail", () => {
|
|
||||||
it("fetches the given recipe URL and returns its html alongside the url", async () => {
|
|
||||||
stubFetchHtml(htmlWithRawJsonLd(RAW_RECIPE_JSON_LD));
|
|
||||||
|
|
||||||
const result = await sevenFiftyGAdapter.fetchDetail(RECIPE_URL);
|
|
||||||
|
|
||||||
expect(result.url).to.equal(RECIPE_URL);
|
|
||||||
expect(result.html).to.include("Poulet au vin jaune");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws a RecipeSourceFetchError keyed to 750g, not the underlying generic adapter", async () => {
|
|
||||||
stubFetchHtml("", 404);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sevenFiftyGAdapter.fetchDetail(RECIPE_URL);
|
|
||||||
expect.fail("expected fetchDetail to throw");
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
|
||||||
expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parse", () => {
|
|
||||||
it("repairs a raw unescaped \\r\\n inside a JSON-LD string that would otherwise fail JSON.parse", () => {
|
|
||||||
const parsed = sevenFiftyGAdapter.parse({
|
|
||||||
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
|
|
||||||
url: RECIPE_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(parsed.name).to.equal("Poulet au vin jaune et aux morilles");
|
|
||||||
expect(parsed.steps).to.deep.equal([
|
|
||||||
{ description: "Préparez les morilles :\r\nFendez-les en deux.", picture: null },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("decodes a double HTML-entity-encoded accented character (é -> é -> &eacute;)", () => {
|
|
||||||
const parsed = sevenFiftyGAdapter.parse({
|
|
||||||
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
|
|
||||||
url: RECIPE_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(parsed.steps[0]?.description).to.include("Préparez");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("decodes a plain numeric apostrophe entity in ingredient text", () => {
|
|
||||||
const parsed = sevenFiftyGAdapter.parse({
|
|
||||||
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
|
|
||||||
url: RECIPE_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(parsed.ingredients).to.deep.equal([
|
|
||||||
{ rawText: "1 poulet fermier", quantity: null, unit: null, name: "1 poulet fermier" },
|
|
||||||
{ rawText: "Sel 'fin'", quantity: null, unit: null, name: "Sel 'fin'" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves picture/sourceUrl untouched by entity decoding", () => {
|
|
||||||
const parsed = sevenFiftyGAdapter.parse({
|
|
||||||
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
|
|
||||||
url: RECIPE_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(parsed.picture).to.equal("https://static.750g.com/images/poulet-vin-jaune.jpg");
|
|
||||||
expect(parsed.sourceUrl).to.equal(RECIPE_URL);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps a recipe with no quirks end to end, same as the generic adapter would", () => {
|
|
||||||
const clean = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "Recipe",
|
|
||||||
name: "Tarte aux pommes",
|
|
||||||
description: "Une tarte classique.",
|
|
||||||
image: "https://static.750g.com/images/tarte.jpg",
|
|
||||||
recipeYield: 6,
|
|
||||||
recipeIngredient: ["3 pommes", "1 pâte brisée"],
|
|
||||||
recipeInstructions: ["Éplucher les pommes.", "Enfourner 30 minutes."],
|
|
||||||
};
|
|
||||||
const html = `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
|
|
||||||
clean,
|
|
||||||
)}</script></head><body></body></html>`;
|
|
||||||
|
|
||||||
const parsed = sevenFiftyGAdapter.parse({ html, url: RECIPE_URL });
|
|
||||||
|
|
||||||
expect(parsed.name).to.equal("Tarte aux pommes");
|
|
||||||
expect(parsed.portions).to.equal(6);
|
|
||||||
expect(parsed.steps).to.deep.equal([
|
|
||||||
{ description: "Éplucher les pommes.", picture: null },
|
|
||||||
{ description: "Enfourner 30 minutes.", picture: null },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws a RecipeSourceParseError keyed to 750g, not the underlying generic adapter", () => {
|
|
||||||
const html = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
|
|
||||||
|
|
||||||
try {
|
|
||||||
sevenFiftyGAdapter.parse({ html, url: RECIPE_URL });
|
|
||||||
expect.fail("expected parse to throw");
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).to.be.instanceOf(RecipeSourceParseError);
|
|
||||||
expect((err as RecipeSourceParseError).sourceKey).to.equal("750g");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
import { expect } from "chai";
|
|
||||||
import {
|
|
||||||
RecipeSourceFetchError,
|
|
||||||
RecipeSourceParseError,
|
|
||||||
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
|
|
||||||
import { mangerBougerAdapter } from "../../src/sources/manger-bouger.js";
|
|
||||||
|
|
||||||
/** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as every other adapter test in this family. */
|
|
||||||
function stubFetchHtml(html: string, status = 200) {
|
|
||||||
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DETAIL_URL =
|
|
||||||
"https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes/2854-salade-de-pates-aux-courgettes";
|
|
||||||
|
|
||||||
/** Wraps a `props.initialState.recipes` payload (the shape `list()` reads) in a minimal `__NEXT_DATA__` script tag, the same server-rendered hydration data every mangerbouger.fr Next.js page carries. */
|
|
||||||
function htmlWithListNextData(recipesState: unknown): string {
|
|
||||||
const payload = { props: { initialState: { recipes: recipesState } } };
|
|
||||||
return `<!doctype html><html><head></head><body><script id="__NEXT_DATA__" type="application/json">${JSON.stringify(
|
|
||||||
payload,
|
|
||||||
)}</script></body></html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A real Slate.js rich-text document (paragraph + bulleted-list of
|
|
||||||
* list-items, the only block types ever observed live), JSON-stringified —
|
|
||||||
* exactly the shape mangerbouger.fr's own JSON-LD embeds as a `HowToStep`'s
|
|
||||||
* `text` field.
|
|
||||||
*/
|
|
||||||
const SLATE_STEP_DOCUMENT = JSON.stringify([
|
|
||||||
{ type: "paragraph", children: [{ text: "Cuisson des courgettes", bold: true }] },
|
|
||||||
{
|
|
||||||
type: "bulleted-list",
|
|
||||||
children: [
|
|
||||||
{ type: "list-item", children: [{ text: "Épluchez les courgettes" }] },
|
|
||||||
{ type: "list-item", children: [{ text: "Coupez-les en rondelles" }] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** A JSON-LD `Recipe` payload shaped exactly like a real mangerbouger.fr detail page's — no `recipeYield` (verified absent live on every sampled recipe), `recipeInstructions` holding {@link SLATE_STEP_DOCUMENT} instead of prose. */
|
|
||||||
const RECIPE_JSON_LD_NO_YIELD = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "Recipe",
|
|
||||||
name: "Salade de pâtes aux courgettes",
|
|
||||||
image: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg",
|
|
||||||
recipeIngredient: ["3 Courgette", "4 cuillères à soupe Huile d'olive"],
|
|
||||||
recipeInstructions: [{ "@type": "HowToStep", name: "Étape 1", text: SLATE_STEP_DOCUMENT }],
|
|
||||||
url: DETAIL_URL,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Wraps a JSON-LD `Recipe` payload (already an object, not yet stringified) and, optionally, a `__NEXT_DATA__` detail-page payload carrying `portions`, in one minimal HTML page — the two independent script tags `parse()` reads. */
|
|
||||||
function htmlWithDetail(recipeJsonLd: unknown, portions?: number): string {
|
|
||||||
const nextData = portions === undefined ? "" : htmlWithDetailNextDataScript(portions);
|
|
||||||
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
|
|
||||||
recipeJsonLd,
|
|
||||||
)}</script></head><body>${nextData}</body></html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function htmlWithDetailNextDataScript(portions: number): string {
|
|
||||||
const payload = { props: { initialState: { recipe: { recipe: { portions } } } } };
|
|
||||||
return `<script id="__NEXT_DATA__" type="application/json">${JSON.stringify(payload)}</script>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("mangerBougerAdapter", () => {
|
|
||||||
let originalFetch: typeof fetch;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
originalFetch = globalThis.fetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("declares itself as an unofficial, French-locale source with an icon", () => {
|
|
||||||
expect(mangerBougerAdapter.key).to.equal("mangerBouger");
|
|
||||||
expect(mangerBougerAdapter.name).to.equal("Manger Bouger");
|
|
||||||
expect(mangerBougerAdapter.official).to.equal(false);
|
|
||||||
expect(mangerBougerAdapter.iconUrl).to.be.a("string");
|
|
||||||
expect(mangerBougerAdapter.locale).to.equal("fr");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("list", () => {
|
|
||||||
it("maps __NEXT_DATA__'s recipes.list into RecipeSourceListItems", async () => {
|
|
||||||
stubFetchHtml(
|
|
||||||
htmlWithListNextData({
|
|
||||||
list: [
|
|
||||||
{
|
|
||||||
id: "2854",
|
|
||||||
slug: "2854-salade-de-pates-aux-courgettes",
|
|
||||||
name: "Salade de pâtes aux courgettes",
|
|
||||||
image: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
hasMorePages: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await mangerBougerAdapter.list({ query: "salade" });
|
|
||||||
|
|
||||||
expect(result.items).to.deep.equal([
|
|
||||||
{
|
|
||||||
externalId: DETAIL_URL,
|
|
||||||
title: "Salade de pâtes aux courgettes",
|
|
||||||
picture: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg",
|
|
||||||
url: DETAIL_URL,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers a next page when hasMorePages is true, and none when false", async () => {
|
|
||||||
stubFetchHtml(htmlWithListNextData({ list: [], hasMorePages: true }));
|
|
||||||
expect((await mangerBougerAdapter.list({ query: "x" })).nextCursor).to.equal("2");
|
|
||||||
|
|
||||||
stubFetchHtml(htmlWithListNextData({ list: [], hasMorePages: false }));
|
|
||||||
expect((await mangerBougerAdapter.list({ query: "x" })).nextCursor).to.be.null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requests the given cursor's page and URL-encodes the query", async () => {
|
|
||||||
let requestedUrl: string | undefined;
|
|
||||||
globalThis.fetch = (async (url: string) => {
|
|
||||||
requestedUrl = url;
|
|
||||||
return new Response(htmlWithListNextData({ list: [], hasMorePages: false }), {
|
|
||||||
status: 200,
|
|
||||||
});
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
await mangerBougerAdapter.list({ query: "crème brûlée", cursor: "3" });
|
|
||||||
|
|
||||||
expect(requestedUrl).to.include("page=3");
|
|
||||||
expect(requestedUrl).to.include("query=cr%C3%A8me%20br%C3%BBl%C3%A9e");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips a list entry missing a slug or a name", async () => {
|
|
||||||
stubFetchHtml(
|
|
||||||
htmlWithListNextData({
|
|
||||||
list: [
|
|
||||||
{ id: "1", name: "No slug", image: null },
|
|
||||||
{ id: "2", slug: "no-name", image: null },
|
|
||||||
],
|
|
||||||
hasMorePages: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await mangerBougerAdapter.list({ query: "x" });
|
|
||||||
|
|
||||||
expect(result.items).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty page rather than throwing when the page has no __NEXT_DATA__ at all", async () => {
|
|
||||||
stubFetchHtml("<!doctype html><html><body>Rien ici</body></html>");
|
|
||||||
|
|
||||||
const result = await mangerBougerAdapter.list({ query: "x" });
|
|
||||||
|
|
||||||
expect(result.items).to.deep.equal([]);
|
|
||||||
expect(result.nextCursor).to.be.null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
|
|
||||||
stubFetchHtml("", 500);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mangerBougerAdapter.list({ query: "x" });
|
|
||||||
expect.fail("expected list to throw");
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
|
||||||
expect((err as RecipeSourceFetchError).sourceKey).to.equal("mangerBouger");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
|
|
||||||
globalThis.fetch = (async () => {
|
|
||||||
throw new Error("network down");
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mangerBougerAdapter.list({ query: "x" });
|
|
||||||
expect.fail("expected list to throw");
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
|
||||||
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchDetail", () => {
|
|
||||||
it("fetches the given recipe URL and returns its html alongside the url", async () => {
|
|
||||||
stubFetchHtml(htmlWithDetail(RECIPE_JSON_LD_NO_YIELD));
|
|
||||||
|
|
||||||
const result = await mangerBougerAdapter.fetchDetail(DETAIL_URL);
|
|
||||||
|
|
||||||
expect(result.url).to.equal(DETAIL_URL);
|
|
||||||
expect(result.html).to.include("Salade de p");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws a RecipeSourceFetchError keyed to mangerBouger, not the underlying generic adapter", async () => {
|
|
||||||
stubFetchHtml("", 404);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mangerBougerAdapter.fetchDetail(DETAIL_URL);
|
|
||||||
expect.fail("expected fetchDetail to throw");
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
|
||||||
expect((err as RecipeSourceFetchError).sourceKey).to.equal("mangerBouger");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parse", () => {
|
|
||||||
it("flattens a Slate.js rich-text step into readable plain text", () => {
|
|
||||||
const parsed = mangerBougerAdapter.parse({
|
|
||||||
html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD),
|
|
||||||
url: DETAIL_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(parsed.steps).to.deep.equal([
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Cuisson des courgettes\n- Épluchez les courgettes\n- Coupez-les en rondelles",
|
|
||||||
picture: null,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("backfills recipeYield/portions from __NEXT_DATA__ when the JSON-LD itself doesn't state one", () => {
|
|
||||||
const parsed = mangerBougerAdapter.parse({
|
|
||||||
html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD, 4),
|
|
||||||
url: DETAIL_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(parsed.portions).to.equal(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves portions null when __NEXT_DATA__ has no portions to backfill from either", () => {
|
|
||||||
const parsed = mangerBougerAdapter.parse({
|
|
||||||
html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD),
|
|
||||||
url: DETAIL_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(parsed.portions).to.be.null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("doesn't override recipeYield when the JSON-LD already states one", () => {
|
|
||||||
const parsed = mangerBougerAdapter.parse({
|
|
||||||
html: htmlWithDetail({ ...RECIPE_JSON_LD_NO_YIELD, recipeYield: 8 }, 4),
|
|
||||||
url: DETAIL_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(parsed.portions).to.equal(8);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves an already-plain-text step untouched rather than mangling it", () => {
|
|
||||||
const parsed = mangerBougerAdapter.parse({
|
|
||||||
html: htmlWithDetail({
|
|
||||||
...RECIPE_JSON_LD_NO_YIELD,
|
|
||||||
recipeInstructions: [{ "@type": "HowToStep", text: "Faites bouillir de l'eau." }],
|
|
||||||
}),
|
|
||||||
url: DETAIL_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(parsed.steps).to.deep.equal([
|
|
||||||
{ description: "Faites bouillir de l'eau.", picture: null },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps name/image/ingredients end to end via the underlying generic adapter", () => {
|
|
||||||
const parsed = mangerBougerAdapter.parse({
|
|
||||||
html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD),
|
|
||||||
url: DETAIL_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(parsed.name).to.equal("Salade de pâtes aux courgettes");
|
|
||||||
expect(parsed.picture).to.equal(
|
|
||||||
"https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg",
|
|
||||||
);
|
|
||||||
expect(parsed.sourceUrl).to.equal(DETAIL_URL);
|
|
||||||
expect(parsed.ingredients).to.deep.equal([
|
|
||||||
{ rawText: "3 Courgette", quantity: null, unit: null, name: "3 Courgette" },
|
|
||||||
{
|
|
||||||
rawText: "4 cuillères à soupe Huile d'olive",
|
|
||||||
quantity: null,
|
|
||||||
unit: null,
|
|
||||||
name: "4 cuillères à soupe Huile d'olive",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws a RecipeSourceParseError keyed to mangerBouger, not the underlying generic adapter", () => {
|
|
||||||
const html = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
|
|
||||||
|
|
||||||
try {
|
|
||||||
mangerBougerAdapter.parse({ html, url: DETAIL_URL });
|
|
||||||
expect.fail("expected parse to throw");
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).to.be.instanceOf(RecipeSourceParseError);
|
|
||||||
expect((err as RecipeSourceParseError).sourceKey).to.equal("mangerBouger");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,226 +0,0 @@
|
||||||
import { expect } from "chai";
|
|
||||||
import {
|
|
||||||
RecipeSourceFetchError,
|
|
||||||
RecipeSourceParseError,
|
|
||||||
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
|
|
||||||
import { marmitonAdapter } from "../../src/sources/marmiton.js";
|
|
||||||
|
|
||||||
/** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as `json-ld-recipe.test.ts`'s `stubFetchHtml`. */
|
|
||||||
function stubFetchHtml(html: string, status = 200) {
|
|
||||||
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps a schema.org `ItemList` payload (already an object, not yet
|
|
||||||
* stringified) in a minimal HTML page carrying it as one
|
|
||||||
* `<script type="application/ld+json">` block — the shape marmiton.org's
|
|
||||||
* search-results page embeds `list()` reads.
|
|
||||||
*/
|
|
||||||
function htmlWithItemListJsonLd(itemListElement: unknown[]): string {
|
|
||||||
const payload = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@graph": [
|
|
||||||
{ "@type": "WebSite", name: "Marmiton" },
|
|
||||||
{
|
|
||||||
"@type": "ItemList",
|
|
||||||
"@id": "https://www.marmiton.org/recettes/recherche.aspx?aqt=poulet#itemlist",
|
|
||||||
numberOfItems: itemListElement.length,
|
|
||||||
itemListElement,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
|
|
||||||
payload,
|
|
||||||
)}</script></head><body></body></html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RECIPE_URL = "https://www.marmiton.org/recettes/recette_tarte-aux-pommes_11457.aspx";
|
|
||||||
|
|
||||||
const baseListItem = {
|
|
||||||
"@type": "ListItem",
|
|
||||||
position: 1,
|
|
||||||
url: RECIPE_URL,
|
|
||||||
name: "Tarte aux pommes",
|
|
||||||
image: "https://assets.afcdn.com/recipe/tarte.jpg",
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseRecipeJsonLd = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "Recipe",
|
|
||||||
name: "Tarte aux pommes",
|
|
||||||
description: "Une tarte aux pommes classique.",
|
|
||||||
image: "https://assets.afcdn.com/recipe/tarte.jpg",
|
|
||||||
recipeYield: "6 personnes",
|
|
||||||
recipeIngredient: ["3 pommes", "1 pâte brisée"],
|
|
||||||
recipeInstructions: [
|
|
||||||
{ "@type": "HowToStep", text: "Épluchez les pommes." },
|
|
||||||
{ "@type": "HowToStep", text: "Enfournez 30 minutes." },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
function htmlWithRecipeJsonLd(): string {
|
|
||||||
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
|
|
||||||
baseRecipeJsonLd,
|
|
||||||
)}</script></head><body></body></html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("marmitonAdapter", () => {
|
|
||||||
let originalFetch: typeof fetch;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
originalFetch = globalThis.fetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("declares itself as an unofficial, French-locale source with an icon", () => {
|
|
||||||
expect(marmitonAdapter.key).to.equal("marmiton");
|
|
||||||
expect(marmitonAdapter.name).to.equal("Marmiton");
|
|
||||||
expect(marmitonAdapter.official).to.equal(false);
|
|
||||||
expect(marmitonAdapter.iconUrl).to.be.a("string");
|
|
||||||
expect(marmitonAdapter.locale).to.equal("fr");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("list", () => {
|
|
||||||
it("maps the search page's ItemList into RecipeSourceListItems and offers a next page", async () => {
|
|
||||||
stubFetchHtml(htmlWithItemListJsonLd([baseListItem]));
|
|
||||||
|
|
||||||
const result = await marmitonAdapter.list({ query: "tarte aux pommes" });
|
|
||||||
|
|
||||||
expect(result.items).to.deep.equal([
|
|
||||||
{
|
|
||||||
externalId: RECIPE_URL,
|
|
||||||
title: "Tarte aux pommes",
|
|
||||||
picture: "https://assets.afcdn.com/recipe/tarte.jpg",
|
|
||||||
url: RECIPE_URL,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(result.nextCursor).to.equal("2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requests the given cursor's page and stops offering a next page once a page comes back empty", async () => {
|
|
||||||
let requestedUrl: string | undefined;
|
|
||||||
globalThis.fetch = (async (url: string) => {
|
|
||||||
requestedUrl = url;
|
|
||||||
return new Response(htmlWithItemListJsonLd([]), { status: 200 });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
const result = await marmitonAdapter.list({ query: "tarte", cursor: "3" });
|
|
||||||
|
|
||||||
expect(requestedUrl).to.include("page=3");
|
|
||||||
expect(result.items).to.deep.equal([]);
|
|
||||||
expect(result.nextCursor).to.be.null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a 404 (page past the last one) as an empty final page, not a failure", async () => {
|
|
||||||
stubFetchHtml("", 404);
|
|
||||||
|
|
||||||
const result = await marmitonAdapter.list({ query: "tarte", cursor: "999" });
|
|
||||||
|
|
||||||
expect(result.items).to.deep.equal([]);
|
|
||||||
expect(result.nextCursor).to.be.null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips a ListItem missing a url or a name", async () => {
|
|
||||||
stubFetchHtml(
|
|
||||||
htmlWithItemListJsonLd([
|
|
||||||
{ "@type": "ListItem", position: 1, name: "No url" },
|
|
||||||
{ "@type": "ListItem", position: 2, url: RECIPE_URL },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await marmitonAdapter.list({ query: "x" });
|
|
||||||
|
|
||||||
expect(result.items).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty page rather than throwing when the page has no ItemList at all", async () => {
|
|
||||||
stubFetchHtml("<!doctype html><html><body>Rien ici</body></html>");
|
|
||||||
|
|
||||||
const result = await marmitonAdapter.list({ query: "x" });
|
|
||||||
|
|
||||||
expect(result.items).to.deep.equal([]);
|
|
||||||
expect(result.nextCursor).to.be.null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws RecipeSourceFetchError on a non-2xx, non-404 response", async () => {
|
|
||||||
stubFetchHtml("", 500);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await marmitonAdapter.list({ query: "x" });
|
|
||||||
expect.fail("expected list to throw");
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
|
|
||||||
globalThis.fetch = (async () => {
|
|
||||||
throw new Error("network down");
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await marmitonAdapter.list({ query: "x" });
|
|
||||||
expect.fail("expected list to throw");
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
|
||||||
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchDetail", () => {
|
|
||||||
it("fetches the given recipe URL and returns its html alongside the url", async () => {
|
|
||||||
stubFetchHtml(htmlWithRecipeJsonLd());
|
|
||||||
|
|
||||||
const result = await marmitonAdapter.fetchDetail(RECIPE_URL);
|
|
||||||
|
|
||||||
expect(result.url).to.equal(RECIPE_URL);
|
|
||||||
expect(result.html).to.include("Tarte aux pommes");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws a RecipeSourceFetchError keyed to marmiton, not the underlying generic adapter", async () => {
|
|
||||||
stubFetchHtml("", 404);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await marmitonAdapter.fetchDetail(RECIPE_URL);
|
|
||||||
expect.fail("expected fetchDetail to throw");
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
|
||||||
expect((err as RecipeSourceFetchError).sourceKey).to.equal("marmiton");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parse", () => {
|
|
||||||
it("delegates to the generic JSON-LD parser end to end", () => {
|
|
||||||
const parsed = marmitonAdapter.parse({ html: htmlWithRecipeJsonLd(), url: RECIPE_URL });
|
|
||||||
|
|
||||||
expect(parsed.name).to.equal("Tarte aux pommes");
|
|
||||||
expect(parsed.portions).to.equal(6);
|
|
||||||
expect(parsed.sourceUrl).to.equal(RECIPE_URL);
|
|
||||||
expect(parsed.ingredients).to.deep.equal([
|
|
||||||
{ rawText: "3 pommes", quantity: null, unit: null, name: "3 pommes" },
|
|
||||||
{ rawText: "1 pâte brisée", quantity: null, unit: null, name: "1 pâte brisée" },
|
|
||||||
]);
|
|
||||||
expect(parsed.steps).to.deep.equal([
|
|
||||||
{ description: "Épluchez les pommes.", picture: null },
|
|
||||||
{ description: "Enfournez 30 minutes.", picture: null },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws a RecipeSourceParseError keyed to marmiton, not the underlying generic adapter", () => {
|
|
||||||
const html = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
|
|
||||||
|
|
||||||
try {
|
|
||||||
marmitonAdapter.parse({ html, url: RECIPE_URL });
|
|
||||||
expect.fail("expected parse to throw");
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).to.be.instanceOf(RecipeSourceParseError);
|
|
||||||
expect((err as RecipeSourceParseError).sourceKey).to.equal("marmiton");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,550 +0,0 @@
|
||||||
import type { SignupInput } from "@batch-cooking/shared";
|
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
|
||||||
import { faker } from "@faker-js/faker";
|
|
||||||
import { expect } from "chai";
|
|
||||||
import request from "supertest";
|
|
||||||
import { createApp } from "../../src/app.js";
|
|
||||||
import { prisma } from "../../src/db/prisma.js";
|
|
||||||
import { resetDatabase } from "../../test-support/reset-db.js";
|
|
||||||
|
|
||||||
/** See `auth.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
|
|
||||||
function buildSignupPayload(): SignupInput {
|
|
||||||
const firstName = faker.person.firstName();
|
|
||||||
const lastName = faker.person.lastName();
|
|
||||||
return {
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
|
||||||
password: faker.internet.password({ length: 16 }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — mirrors `recipe.test.ts`'s own `techStepId` helper. */
|
|
||||||
async function techStepId(key: string): Promise<number> {
|
|
||||||
const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } });
|
|
||||||
return techStep.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same as {@link techStepId}, for a reference `Ingredient`. */
|
|
||||||
async function ingredientId(key: string): Promise<number> {
|
|
||||||
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
|
|
||||||
return ingredient.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same as {@link techStepId}, for a reference `Unit`. */
|
|
||||||
async function unitId(key: string): Promise<number> {
|
|
||||||
const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
|
|
||||||
return unit.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same as {@link techStepId}, for a reference `Utensil`. */
|
|
||||||
async function utensilId(key: string): Promise<number> {
|
|
||||||
const utensil = await prisma.utensil.findFirstOrThrow({ where: { key } });
|
|
||||||
return utensil.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Recipe tech-step corrections", () => {
|
|
||||||
const app = createApp();
|
|
||||||
|
|
||||||
async function signup(): Promise<{ agent: ReturnType<typeof request.agent>; profileId: number }> {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
const res = await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
return { agent, profileId: res.body.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A `PUBLIC` recipe with one step — every viewer can see this, so most tests below don't need to juggle visibility on top of the correction logic itself. */
|
|
||||||
async function createPublicRecipeWithStep(
|
|
||||||
authorId: number,
|
|
||||||
description = "Faire mijoter la sauce.",
|
|
||||||
): Promise<{ recipeId: number; stepId: number }> {
|
|
||||||
const recipe = await prisma.recipe.create({
|
|
||||||
data: {
|
|
||||||
name: "Recette",
|
|
||||||
authorId,
|
|
||||||
visibility: "PUBLIC",
|
|
||||||
portions: 4,
|
|
||||||
steps: { create: [{ description, order: 0 }] },
|
|
||||||
},
|
|
||||||
include: { steps: true },
|
|
||||||
});
|
|
||||||
const step = recipe.steps[0];
|
|
||||||
if (!step) throw new Error("expected the fixture recipe to have one step");
|
|
||||||
return { recipeId: recipe.id, stepId: step.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("POST /recipes/:id/steps/:stepId/corrections", () => {
|
|
||||||
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
|
||||||
const { profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
|
||||||
.send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(401);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("records a correction adding a missing technique (no previousTechStepId), and applies it immediately to the step's own techSteps", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
// "Faire mijoter la sauce." names no technique the classifier itself
|
|
||||||
// registers a bare-word anchor for at this exact span in isolation
|
|
||||||
// (see services/tech-step-intent-service's training_data.py) — irrelevant here either way,
|
|
||||||
// since this test's whole point is the *manual* addition, not
|
|
||||||
// whatever the classifier does or doesn't auto-detect for it.
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
const simmerId = await techStepId("simmer");
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
|
||||||
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
expect(res.body.correction.previousTechStep).to.equal(null);
|
|
||||||
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
|
||||||
expect(res.body.correction.start).to.equal(6);
|
|
||||||
expect(res.body.correction.end).to.equal(13);
|
|
||||||
// The step's real technique sequence reflects the correction right
|
|
||||||
// away — not just the permanent audit record above (see
|
|
||||||
// `applyManualCorrection`, `recipe-tech-step-correction.service.ts`).
|
|
||||||
expect(res.body.techSteps).to.deep.equal([
|
|
||||||
{
|
|
||||||
techStep: { id: simmerId, key: "simmer" },
|
|
||||||
start: 6,
|
|
||||||
end: 13,
|
|
||||||
source: "manual",
|
|
||||||
ingredients: [],
|
|
||||||
utensils: [],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("records a correction relabeling an existing match (both ids set), updating the existing techSteps entry in place", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
const simmerId = await techStepId("simmer");
|
|
||||||
const boilId = await techStepId("boil");
|
|
||||||
// First correction creates the "manual" entry this test then relabels
|
|
||||||
// — exercises the UPDATE branch of `applyManualCorrection`, not the
|
|
||||||
// INSERT one the previous test already covers.
|
|
||||||
await agent
|
|
||||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
|
||||||
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
|
||||||
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
expect(res.body.correction.previousTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
|
||||||
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
|
|
||||||
// Still exactly one entry — the relabel updated the existing row
|
|
||||||
// rather than adding a second one alongside it.
|
|
||||||
expect(res.body.techSteps).to.deep.equal([
|
|
||||||
{
|
|
||||||
techStep: { id: boilId, key: "boil" },
|
|
||||||
start: 6,
|
|
||||||
end: 13,
|
|
||||||
source: "manual",
|
|
||||||
ingredients: [],
|
|
||||||
utensils: [],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deletes the matching techSteps entry when correctedTechStepId is null (a removal)", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
const simmerId = await techStepId("simmer");
|
|
||||||
await agent
|
|
||||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
|
||||||
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
|
||||||
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: null });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
expect(res.body.correction.correctedTechStep).to.equal(null);
|
|
||||||
expect(res.body.techSteps).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is not restricted to the recipe's author — any viewer who can see it may correct it", async () => {
|
|
||||||
const { profileId: authorId } = await signup();
|
|
||||||
const { agent: otherAgent } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(authorId);
|
|
||||||
|
|
||||||
const res = await otherAgent
|
|
||||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
|
||||||
.send({ start: 6, end: 13, correctedTechStepId: await techStepId("simmer") });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects both previousTechStepId and correctedTechStepId absent with 400 VALIDATION_ERROR", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
|
||||||
.send({ start: 0, end: 5 });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects end <= start with 400 VALIDATION_ERROR", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
|
||||||
.send({ start: 5, end: 5, correctedTechStepId: await techStepId("simmer") });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a span past the end of the step's description with 400 INVALID_CORRECTION_SPAN", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const description = "Court.";
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description);
|
|
||||||
|
|
||||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
|
||||||
start: 0,
|
|
||||||
end: description.length + 10,
|
|
||||||
correctedTechStepId: await techStepId("simmer"),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.INVALID_CORRECTION_SPAN);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an unknown correctedTechStepId with 404 TECH_STEP_NOT_FOUND", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
|
||||||
.send({ start: 0, end: 5, correctedTechStepId: 999_999 });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(404);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a step that exists but isn't visible to the viewer with 404 RECIPE_NOT_FOUND", async () => {
|
|
||||||
const { profileId: authorId } = await signup();
|
|
||||||
const { agent: otherAgent } = await signup();
|
|
||||||
const recipe = await prisma.recipe.create({
|
|
||||||
data: {
|
|
||||||
name: "Secrète",
|
|
||||||
authorId,
|
|
||||||
portions: 4,
|
|
||||||
steps: { create: [{ description: "Faire mijoter la sauce.", order: 0 }] },
|
|
||||||
},
|
|
||||||
include: { steps: true },
|
|
||||||
});
|
|
||||||
const step = recipe.steps[0];
|
|
||||||
if (!step) throw new Error("expected the fixture recipe to have one step");
|
|
||||||
|
|
||||||
const res = await otherAgent
|
|
||||||
.post(`/recipes/${recipe.id}/steps/${step.id}/corrections`)
|
|
||||||
.send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(404);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a stepId that belongs to a different recipe than the URL's :id with 404 STEP_NOT_FOUND", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId: otherRecipeId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
const { stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
|
|
||||||
const res = await agent
|
|
||||||
.post(`/recipes/${otherRecipeId}/steps/${stepId}/corrections`)
|
|
||||||
.send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(404);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.STEP_NOT_FOUND);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("POST /recipes/:id/steps/:stepId/corrections — ingredients/utensils metadata", () => {
|
|
||||||
it("attaches manually-selected ingredients and utensils to a corrected technique", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
const simmerId = await techStepId("simmer");
|
|
||||||
const butterId = await ingredientId("butter");
|
|
||||||
const gramId = await unitId("gram");
|
|
||||||
const panId = await utensilId("pan");
|
|
||||||
|
|
||||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
|
||||||
start: 6,
|
|
||||||
end: 13,
|
|
||||||
correctedTechStepId: simmerId,
|
|
||||||
ingredients: [{ ingredientId: butterId, quantity: 50, unitId: gramId, start: 0, end: 6 }],
|
|
||||||
utensils: [{ utensilId: panId, start: 14, end: 23 }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
expect(res.body.techSteps).to.deep.equal([
|
|
||||||
{
|
|
||||||
techStep: { id: simmerId, key: "simmer" },
|
|
||||||
start: 6,
|
|
||||||
end: 13,
|
|
||||||
source: "manual",
|
|
||||||
ingredients: [
|
|
||||||
{
|
|
||||||
ingredient: res.body.techSteps[0].ingredients[0].ingredient,
|
|
||||||
quantity: 50,
|
|
||||||
unit: res.body.techSteps[0].ingredients[0].unit,
|
|
||||||
start: 0,
|
|
||||||
end: 6,
|
|
||||||
source: "manual",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
utensils: [
|
|
||||||
{
|
|
||||||
utensil: res.body.techSteps[0].utensils[0].utensil,
|
|
||||||
start: 14,
|
|
||||||
end: 23,
|
|
||||||
source: "manual",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(res.body.techSteps[0].ingredients[0].ingredient.id).to.equal(butterId);
|
|
||||||
expect(res.body.techSteps[0].ingredients[0].unit.id).to.equal(gramId);
|
|
||||||
expect(res.body.techSteps[0].utensils[0].utensil).to.deep.equal({ id: panId, key: "pan" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("attaches an ingredient with no quantity/unit (both omitted)", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
const simmerId = await techStepId("simmer");
|
|
||||||
const butterId = await ingredientId("butter");
|
|
||||||
|
|
||||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
|
||||||
start: 6,
|
|
||||||
end: 13,
|
|
||||||
correctedTechStepId: simmerId,
|
|
||||||
ingredients: [{ ingredientId: butterId, start: 0, end: 6 }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
expect(res.body.techSteps[0].ingredients[0].quantity).to.equal(null);
|
|
||||||
expect(res.body.techSteps[0].ingredients[0].unit).to.equal(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces both auto-detected and previously-manual metadata on the same occurrence — never accumulates", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
const simmerId = await techStepId("simmer");
|
|
||||||
const boilId = await techStepId("boil");
|
|
||||||
const butterId = await ingredientId("butter");
|
|
||||||
const carrotId = await ingredientId("carrot");
|
|
||||||
const panId = await utensilId("pan");
|
|
||||||
const saucepanId = await utensilId("saucepan");
|
|
||||||
|
|
||||||
// First correction creates the occurrence (order 0) — simulate an
|
|
||||||
// auto-detected ingredient already sitting on it, exactly as
|
|
||||||
// tech-step-matcher.ts would have written one at save time (bypassed
|
|
||||||
// here for a deterministic fixture, not dependent on the real
|
|
||||||
// classifier's own output for this text).
|
|
||||||
await agent
|
|
||||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
|
||||||
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
|
|
||||||
await prisma.stepTechStepIngredient.create({
|
|
||||||
data: {
|
|
||||||
stepId,
|
|
||||||
techStepOrder: 0,
|
|
||||||
ingredientId: butterId,
|
|
||||||
start: 0,
|
|
||||||
end: 6,
|
|
||||||
source: "auto",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prisma.stepTechStepUtensil.create({
|
|
||||||
data: { stepId, techStepOrder: 0, utensilId: panId, start: 14, end: 23, source: "auto" },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Second correction — relabels the technique *and* submits a whole
|
|
||||||
// new, disjoint metadata set.
|
|
||||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
|
||||||
start: 6,
|
|
||||||
end: 13,
|
|
||||||
previousTechStepId: simmerId,
|
|
||||||
correctedTechStepId: boilId,
|
|
||||||
ingredients: [{ ingredientId: carrotId, start: 0, end: 6 }],
|
|
||||||
utensils: [{ utensilId: saucepanId, start: 14, end: 23 }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
expect(res.body.techSteps).to.have.length(1);
|
|
||||||
// Neither the auto-detected butter/pan nor an empty leftover row
|
|
||||||
// survive — only the freshly-submitted carrot/saucepan.
|
|
||||||
expect(
|
|
||||||
res.body.techSteps[0].ingredients.map(
|
|
||||||
(i: { ingredient: { id: number } }) => i.ingredient.id,
|
|
||||||
),
|
|
||||||
).to.deep.equal([carrotId]);
|
|
||||||
expect(
|
|
||||||
res.body.techSteps[0].utensils.map((u: { utensil: { id: number } }) => u.utensil.id),
|
|
||||||
).to.deep.equal([saucepanId]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves existing metadata untouched when ingredients/utensils are omitted from the request", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
const simmerId = await techStepId("simmer");
|
|
||||||
const boilId = await techStepId("boil");
|
|
||||||
const butterId = await ingredientId("butter");
|
|
||||||
|
|
||||||
await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
|
||||||
start: 6,
|
|
||||||
end: 13,
|
|
||||||
correctedTechStepId: simmerId,
|
|
||||||
ingredients: [{ ingredientId: butterId, start: 0, end: 6 }],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Relabels the technique again, but says nothing about metadata at all.
|
|
||||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
|
||||||
start: 6,
|
|
||||||
end: 13,
|
|
||||||
previousTechStepId: simmerId,
|
|
||||||
correctedTechStepId: boilId,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
expect(res.body.techSteps[0].ingredients).to.have.length(1);
|
|
||||||
expect(res.body.techSteps[0].ingredients[0].ingredient.id).to.equal(butterId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects metadata submitted alongside correctedTechStepId: null with 400 VALIDATION_ERROR", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
const simmerId = await techStepId("simmer");
|
|
||||||
const butterId = await ingredientId("butter");
|
|
||||||
await agent
|
|
||||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
|
||||||
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
|
|
||||||
|
|
||||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
|
||||||
start: 6,
|
|
||||||
end: 13,
|
|
||||||
previousTechStepId: simmerId,
|
|
||||||
correctedTechStepId: null,
|
|
||||||
ingredients: [{ ingredientId: butterId, start: 0, end: 6 }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
|
|
||||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
|
||||||
start: 6,
|
|
||||||
end: 13,
|
|
||||||
correctedTechStepId: await techStepId("simmer"),
|
|
||||||
ingredients: [{ ingredientId: 999_999, start: 0, end: 6 }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(404);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an unknown unitId with 404 UNIT_NOT_FOUND", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
|
|
||||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
|
||||||
start: 6,
|
|
||||||
end: 13,
|
|
||||||
correctedTechStepId: await techStepId("simmer"),
|
|
||||||
ingredients: [
|
|
||||||
{ ingredientId: await ingredientId("butter"), unitId: 999_999, start: 0, end: 6 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(404);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an unknown utensilId with 404 UTENSIL_NOT_FOUND", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
|
|
||||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
|
||||||
start: 6,
|
|
||||||
end: 13,
|
|
||||||
correctedTechStepId: await techStepId("simmer"),
|
|
||||||
utensils: [{ utensilId: 999_999, start: 0, end: 6 }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(404);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.UTENSIL_NOT_FOUND);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a metadata span past the end of the step's description with 400 INVALID_CORRECTION_SPAN", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const description = "Court.";
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description);
|
|
||||||
|
|
||||||
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
|
|
||||||
start: 0,
|
|
||||||
end: description.length,
|
|
||||||
correctedTechStepId: await techStepId("simmer"),
|
|
||||||
ingredients: [
|
|
||||||
{ ingredientId: await ingredientId("butter"), start: 0, end: description.length + 10 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.INVALID_CORRECTION_SPAN);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /recipes/:id/steps/:stepId/corrections", () => {
|
|
||||||
it("returns every correction submitted for the step, most recent first", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
const simmerId = await techStepId("simmer");
|
|
||||||
const boilId = await techStepId("boil");
|
|
||||||
|
|
||||||
await agent
|
|
||||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
|
||||||
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
|
|
||||||
await agent
|
|
||||||
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
|
|
||||||
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
|
|
||||||
|
|
||||||
const res = await agent.get(`/recipes/${recipeId}/steps/${stepId}/corrections`);
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body).to.have.length(2);
|
|
||||||
expect(res.body[0].correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
|
|
||||||
expect(res.body[1].correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty list when nothing has been submitted yet", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
|
||||||
|
|
||||||
const res = await agent.get(`/recipes/${recipeId}/steps/${stepId}/corrections`);
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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, TECH_STEPS, UTENSILS } from "../src/db/reference-seed-data.js";
|
import { seedReferenceData } from "../src/db/reference-seed-data.js";
|
||||||
import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
|
import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
|
||||||
import {
|
import {
|
||||||
clearRecipeSources,
|
clearRecipeSources,
|
||||||
|
|
@ -137,9 +137,7 @@ 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);
|
||||||
// `TECH_STEPS.length` (reference-seed-data.ts), not a hardcoded
|
expect(res.body).to.have.length(26);
|
||||||
// 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"]);
|
||||||
});
|
});
|
||||||
|
|
@ -157,32 +155,7 @@ 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(TECH_STEPS.length);
|
expect(res.body).to.have.length(26);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,336 +0,0 @@
|
||||||
import type { DateTime } from "@batch-cooking/date-tools";
|
|
||||||
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
|
|
||||||
import { faker } from "@faker-js/faker";
|
|
||||||
import { expect } from "chai";
|
|
||||||
import request from "supertest";
|
|
||||||
import { createApp } from "../src/app.js";
|
|
||||||
import { prisma } from "../src/db/prisma.js";
|
|
||||||
import { TEST_REFERENCE_DATE } from "../test-support/reference-date.js";
|
|
||||||
import { resetDatabase } from "../test-support/reset-db.js";
|
|
||||||
|
|
||||||
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
|
|
||||||
function buildSignupPayload(): SignupInput {
|
|
||||||
const firstName = faker.person.firstName();
|
|
||||||
const lastName = faker.person.lastName();
|
|
||||||
return {
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
|
||||||
password: faker.internet.password({ length: 16 }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The fixed test "today", as the `YYYY-MM-DD` string `GET /shopping-list`'s `?date=` expects. */
|
|
||||||
function today(): string {
|
|
||||||
return isoDate(TEST_REFERENCE_DATE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `toISODate()` only returns `null` for an invalid `DateTime` — never the case for the always-valid values built in this file. */
|
|
||||||
function isoDate(date: DateTime): string {
|
|
||||||
const iso = date.toISODate();
|
|
||||||
if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper");
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helper as `recipe.test.ts`. */
|
|
||||||
async function ingredientId(key: string): Promise<number> {
|
|
||||||
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
|
|
||||||
return ingredient.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves a reference unit's id by its `reference-seed-data.ts` uid — same helper as `recipe.test.ts`. */
|
|
||||||
async function unitId(key: string): Promise<number> {
|
|
||||||
const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
|
|
||||||
return unit.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Shopping list", () => {
|
|
||||||
const app = createApp();
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /shopping-list", () => {
|
|
||||||
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
|
||||||
const res = await request(app).get("/shopping-list").query({ date: today() });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(401);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a missing date with 400 VALIDATION_ERROR", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
|
|
||||||
const res = await agent.get("/shopping-list");
|
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a malformed date with 400 VALIDATION_ERROR", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
|
|
||||||
const res = await agent.get("/shopping-list").query({ date: "not-a-date" });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a date shaped right but calendarially impossible with 400 VALIDATION_ERROR", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
|
|
||||||
const res = await agent.get("/shopping-list").query({ date: "2026-02-30" });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty list when the profile has no household", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
|
|
||||||
const res = await agent.get("/shopping-list").query({ date: today() });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body.items).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty list when the household has no planning covering that date", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
await agent.post("/house").send({ name: "Chez moi" });
|
|
||||||
|
|
||||||
const res = await agent.get("/shopping-list").query({ date: today() });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body.items).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sums one recipe's ingredient across two planning slots, scaled by each slot's own portions", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
|
||||||
const houseId: number = houseRes.body.id;
|
|
||||||
const authorId: number = houseRes.body.adminId;
|
|
||||||
|
|
||||||
const tomatoId = await ingredientId("tomato");
|
|
||||||
const gramId = await unitId("gram");
|
|
||||||
|
|
||||||
// Written for 2 portions, 100g tomato — planned twice this week at
|
|
||||||
// 4 portions each, so the shopping list should show 100 × (4/2) × 2
|
|
||||||
// = 400g, not the raw 200g the recipe itself lists.
|
|
||||||
const recipe = await prisma.recipe.create({
|
|
||||||
data: {
|
|
||||||
name: "Salade de tomates",
|
|
||||||
authorId,
|
|
||||||
portions: 2,
|
|
||||||
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const planning = await prisma.planning.create({
|
|
||||||
data: {
|
|
||||||
houseId,
|
|
||||||
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
|
|
||||||
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prisma.planningItem.createMany({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
planningId: planning.id,
|
|
||||||
weekDay: "lundi",
|
|
||||||
meal: "dejeuner",
|
|
||||||
recipeId: recipe.id,
|
|
||||||
portions: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
planningId: planning.id,
|
|
||||||
weekDay: "mercredi",
|
|
||||||
meal: "diner",
|
|
||||||
recipeId: recipe.id,
|
|
||||||
portions: 4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await agent.get("/shopping-list").query({ date: today() });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body.items).to.have.length(1);
|
|
||||||
expect(res.body.items[0].ingredient.key).to.equal("tomato");
|
|
||||||
expect(res.body.items[0].unit.key).to.equal("gram");
|
|
||||||
expect(res.body.items[0].quantity).to.equal(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sums the same ingredient across two different recipes sharing a unit", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
|
||||||
const houseId: number = houseRes.body.id;
|
|
||||||
const authorId: number = houseRes.body.adminId;
|
|
||||||
|
|
||||||
const onionId = await ingredientId("onion");
|
|
||||||
const gramId = await unitId("gram");
|
|
||||||
|
|
||||||
const recipeA = await prisma.recipe.create({
|
|
||||||
data: {
|
|
||||||
name: "Soupe à l'oignon",
|
|
||||||
authorId,
|
|
||||||
portions: 4,
|
|
||||||
ingredients: { create: [{ ingredientId: onionId, quantity: 200, unitId: gramId }] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const recipeB = await prisma.recipe.create({
|
|
||||||
data: {
|
|
||||||
name: "Tarte à l'oignon",
|
|
||||||
authorId,
|
|
||||||
portions: 4,
|
|
||||||
ingredients: { create: [{ ingredientId: onionId, quantity: 150, unitId: gramId }] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const planning = await prisma.planning.create({
|
|
||||||
data: {
|
|
||||||
houseId,
|
|
||||||
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
|
|
||||||
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prisma.planningItem.createMany({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
planningId: planning.id,
|
|
||||||
weekDay: "lundi",
|
|
||||||
meal: "dejeuner",
|
|
||||||
recipeId: recipeA.id,
|
|
||||||
portions: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
planningId: planning.id,
|
|
||||||
weekDay: "mardi",
|
|
||||||
meal: "diner",
|
|
||||||
recipeId: recipeB.id,
|
|
||||||
portions: 4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await agent.get("/shopping-list").query({ date: today() });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body.items).to.have.length(1);
|
|
||||||
expect(res.body.items[0].ingredient.key).to.equal("onion");
|
|
||||||
expect(res.body.items[0].quantity).to.equal(350);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the same ingredient in two different units as two separate lines", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
|
||||||
const houseId: number = houseRes.body.id;
|
|
||||||
const authorId: number = houseRes.body.adminId;
|
|
||||||
|
|
||||||
const tomatoId = await ingredientId("tomato");
|
|
||||||
const gramId = await unitId("gram");
|
|
||||||
const kilogramId = await unitId("kilogram");
|
|
||||||
|
|
||||||
const recipeA = await prisma.recipe.create({
|
|
||||||
data: {
|
|
||||||
name: "Recette A",
|
|
||||||
authorId,
|
|
||||||
portions: 2,
|
|
||||||
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const recipeB = await prisma.recipe.create({
|
|
||||||
data: {
|
|
||||||
name: "Recette B",
|
|
||||||
authorId,
|
|
||||||
portions: 2,
|
|
||||||
ingredients: { create: [{ ingredientId: tomatoId, quantity: 1, unitId: kilogramId }] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const planning = await prisma.planning.create({
|
|
||||||
data: {
|
|
||||||
houseId,
|
|
||||||
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
|
|
||||||
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prisma.planningItem.createMany({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
planningId: planning.id,
|
|
||||||
weekDay: "lundi",
|
|
||||||
meal: "dejeuner",
|
|
||||||
recipeId: recipeA.id,
|
|
||||||
portions: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
planningId: planning.id,
|
|
||||||
weekDay: "mardi",
|
|
||||||
meal: "diner",
|
|
||||||
recipeId: recipeB.id,
|
|
||||||
portions: 2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await agent.get("/shopping-list").query({ date: today() });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body.items).to.have.length(2);
|
|
||||||
const units = res.body.items.map((item: { unit: { key: string } }) => item.unit.key).sort();
|
|
||||||
expect(units).to.deep.equal(["gram", "kilogram"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a different week's shopping list when asked for a date outside the current one", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
|
||||||
const houseId: number = houseRes.body.id;
|
|
||||||
const authorId: number = houseRes.body.adminId;
|
|
||||||
|
|
||||||
const tomatoId = await ingredientId("tomato");
|
|
||||||
const gramId = await unitId("gram");
|
|
||||||
const recipe = await prisma.recipe.create({
|
|
||||||
data: {
|
|
||||||
name: "Curry de lentilles",
|
|
||||||
authorId,
|
|
||||||
portions: 2,
|
|
||||||
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 });
|
|
||||||
const planning = await prisma.planning.create({
|
|
||||||
data: {
|
|
||||||
houseId,
|
|
||||||
startDate: nextWeek.startOf("week").toJSDate(),
|
|
||||||
finishDate: nextWeek.endOf("week").startOf("day").toJSDate(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prisma.planningItem.create({
|
|
||||||
data: {
|
|
||||||
planningId: planning.id,
|
|
||||||
weekDay: "mardi",
|
|
||||||
meal: "dejeuner",
|
|
||||||
recipeId: recipe.id,
|
|
||||||
portions: 2,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextWeekRes = await agent.get("/shopping-list").query({ date: isoDate(nextWeek) });
|
|
||||||
expect(nextWeekRes.body.items).to.have.length(1);
|
|
||||||
|
|
||||||
const thisWeekRes = await agent.get("/shopping-list").query({ date: today() });
|
|
||||||
expect(thisWeekRes.body.items).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -25,33 +25,6 @@ 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,55 +120,6 @@ function buildDuplicateIngredientAdapter(key = "duplicateFakeSource"): RecipeSou
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A minimal French-content fake adapter — same shape as {@link buildFakeAdapter},
|
|
||||||
* `locale: "fr"` instead of `"en"`. Exercises `previewSourceItem` actually
|
|
||||||
* resolving ingredients for a non-English source through the real HTTP
|
|
||||||
* endpoint/catalog: `loadIngredientCatalog`/`loadUnitCatalog` used to be
|
|
||||||
* called only for `locale === "en"`, silently leaving every ingredient
|
|
||||||
* unresolved for a French source like Marmiton/750g/Manger Bouger — the
|
|
||||||
* regression this test guards against.
|
|
||||||
*/
|
|
||||||
function buildFrenchFakeAdapter(key = "fakeFrSource"): RecipeSourceAdapter<{ externalId: string }> {
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
name: "Fake French Source",
|
|
||||||
official: true,
|
|
||||||
iconUrl: null,
|
|
||||||
locale: "fr",
|
|
||||||
async list(_params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
|
||||||
return {
|
|
||||||
items: [
|
|
||||||
{ externalId: "1", title: "Soupe à l'oignon", picture: null, url: "https://fake.test/1" },
|
|
||||||
],
|
|
||||||
nextCursor: null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async fetchDetail(externalId: string): Promise<{ externalId: string }> {
|
|
||||||
return { externalId };
|
|
||||||
},
|
|
||||||
parse(raw: { externalId: string }): ParsedRecipe {
|
|
||||||
return {
|
|
||||||
name: `Recette factice ${raw.externalId}`,
|
|
||||||
description: null,
|
|
||||||
picture: null,
|
|
||||||
portions: 4,
|
|
||||||
sourceUrl: `https://fake.test/${raw.externalId}`,
|
|
||||||
ingredients: [
|
|
||||||
{ rawText: "3 carottes", quantity: null, unit: null, name: "carottes" },
|
|
||||||
{
|
|
||||||
rawText: "un ingrédient mystère",
|
|
||||||
quantity: null,
|
|
||||||
unit: null,
|
|
||||||
name: "ingrédient mystère",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
steps: [{ description: "Faire mijoter à feu doux", picture: null }],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as `recipe.test.ts`'s own helper. */
|
/** 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 } });
|
||||||
|
|
@ -354,37 +305,6 @@ 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());
|
||||||
|
|
|
||||||
|
|
@ -1,277 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import "../../src/i18n/i18n";
|
|
||||||
import { TechStepCorrectionPopover } from "../../src/features/recipes/steps/TechStepCorrectionPopover";
|
|
||||||
|
|
||||||
// Mounts the popover in isolation (no StepDescription/selection plumbing
|
|
||||||
// around it) — same "generic component test" posture as CheckboxOption.cy.tsx,
|
|
||||||
// but this one needs `../../src/i18n/i18n` imported for its side effect
|
|
||||||
// (initializes the default i18next instance `useTranslation` falls back to
|
|
||||||
// with no `<I18nextProvider>` in the tree — see that module's own doc
|
|
||||||
// comment) since, unlike Checkbox/Radio, this component calls
|
|
||||||
// `useTranslation()`.
|
|
||||||
|
|
||||||
const cook = { id: 1, key: "cook" };
|
|
||||||
const simmer = { id: 3, key: "simmer" };
|
|
||||||
const butter = { id: 10, key: "butter" };
|
|
||||||
const pan = { id: 20, key: "pan" };
|
|
||||||
const gram = { id: 30, key: "gram" };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A real `StepDescription` resolves `onRequestSpan` into a fresh
|
|
||||||
* `resolvedMetadataSpan` via an actual browser text selection — out of
|
|
||||||
* scope for a component test of the popover alone (covered by the e2e
|
|
||||||
* scenario instead). This harness fakes that round-trip with a fixed
|
|
||||||
* span, so tests here can exercise everything the popover itself is
|
|
||||||
* responsible for once a span comes back, without needing a real
|
|
||||||
* `StepDescription` in the tree.
|
|
||||||
*/
|
|
||||||
function Harness({
|
|
||||||
previousTechStepId = null,
|
|
||||||
existingIngredients = [],
|
|
||||||
existingUtensils = [],
|
|
||||||
onClose = () => {},
|
|
||||||
onSubmitted = () => {},
|
|
||||||
}: Partial<{
|
|
||||||
previousTechStepId: number | null;
|
|
||||||
existingIngredients: unknown[];
|
|
||||||
existingUtensils: unknown[];
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmitted: (result: unknown) => void;
|
|
||||||
}>) {
|
|
||||||
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
|
|
||||||
nonce: number;
|
|
||||||
kind: "ingredient" | "utensil";
|
|
||||||
range: { start: number; end: number };
|
|
||||||
text: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* A genuinely separate sibling to click for the "outside click closes it" test — clicking blindly at a viewport coordinate would risk still landing inside the popover, which fills most of the mounted area on its own. */}
|
|
||||||
<div data-testid="outside-popover" style={{ height: 20 }} />
|
|
||||||
<TechStepCorrectionPopover
|
|
||||||
recipeId={2}
|
|
||||||
stepId={2}
|
|
||||||
selectedText="Cuire"
|
|
||||||
range={{ start: 0, end: 5 }}
|
|
||||||
previousTechStepId={previousTechStepId}
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView props — precise typing isn't the point here.
|
|
||||||
existingIngredients={existingIngredients as any}
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
||||||
existingUtensils={existingUtensils as any}
|
|
||||||
resolvedMetadataSpan={resolvedMetadataSpan}
|
|
||||||
onRequestSpan={(kind) =>
|
|
||||||
setResolvedMetadataSpan({
|
|
||||||
nonce: Date.now(),
|
|
||||||
kind,
|
|
||||||
range: { start: 20, end: 26 },
|
|
||||||
text: "Beurre",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onClose={onClose}
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
||||||
onSubmitted={onSubmitted as any}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("TechStepCorrectionPopover", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.intercept("GET", "**/reference/tech-steps", { statusCode: 200, body: [cook, simmer] }).as(
|
|
||||||
"getTechSteps",
|
|
||||||
);
|
|
||||||
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [butter] }).as(
|
|
||||||
"getIngredients",
|
|
||||||
);
|
|
||||||
cy.intercept("GET", "**/reference/units", { statusCode: 200, body: [gram] }).as("getUnits");
|
|
||||||
cy.intercept("GET", "**/reference/utensils", { statusCode: 200, body: [pan] }).as(
|
|
||||||
"getUtensils",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the selected text, the technique catalog (searchable) and the metadata sections all together", () => {
|
|
||||||
cy.mount(<Harness />);
|
|
||||||
cy.wait("@getTechSteps");
|
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
|
|
||||||
// Merged editor (see TechStepCorrectionPopover's own doc comment) — no
|
|
||||||
// separate "pick, then metadata reveals itself" step, both render at
|
|
||||||
// once, and the technique catalog goes through the same searchable
|
|
||||||
// `CatalogSearchPicker` as the ingredient/utensil sub-flows (a plain
|
|
||||||
// unfiltered list of the real ~74-entry catalog isn't browsable).
|
|
||||||
cy.get(
|
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
||||||
).should("have.length", 2);
|
|
||||||
cy.contains("h4", "Ingrédients").should("be.visible");
|
|
||||||
cy.contains("h4", "Ustensiles").should("be.visible");
|
|
||||||
cy.contains("button", "Valider").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers a 'no technique here' option, and marks the current pick, only when correcting an existing match", () => {
|
|
||||||
cy.mount(<Harness previousTechStepId={null} />);
|
|
||||||
cy.wait("@getTechSteps");
|
|
||||||
cy.get(".tech-step-correction-popover__remove").should("not.exist");
|
|
||||||
cy.contains(".tech-step-correction-popover__chosen-technique", "Aucune technique sélectionnée");
|
|
||||||
|
|
||||||
cy.mount(<Harness previousTechStepId={cook.id} />);
|
|
||||||
cy.wait("@getTechSteps");
|
|
||||||
cy.get(".tech-step-correction-popover__remove").should("exist");
|
|
||||||
cy.contains(".tech-step-correction-popover__chosen-technique", "Cuire");
|
|
||||||
cy.contains(".catalog-search-picker__list button", "Cuire").should(
|
|
||||||
"have.class",
|
|
||||||
"catalog-search-picker__item--selected",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("picking a technique from the catalog selects it without submitting immediately", () => {
|
|
||||||
cy.mount(<Harness />);
|
|
||||||
cy.wait("@getTechSteps");
|
|
||||||
|
|
||||||
cy.contains(
|
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
||||||
"Mijoter",
|
|
||||||
).click();
|
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__chosen-technique", "Mijoter");
|
|
||||||
cy.contains("button", "Valider").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Valider stays disabled until a technique is actually picked", () => {
|
|
||||||
cy.mount(<Harness />);
|
|
||||||
cy.wait("@getTechSteps");
|
|
||||||
|
|
||||||
cy.contains("button", "Valider").should("be.disabled");
|
|
||||||
cy.contains(
|
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
||||||
"Mijoter",
|
|
||||||
).click();
|
|
||||||
cy.contains("button", "Valider").should("not.be.disabled");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("submits the selected technique (no metadata touched) with ingredients/utensils omitted from the request", () => {
|
|
||||||
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
|
||||||
statusCode: 201,
|
|
||||||
body: {
|
|
||||||
id: 1,
|
|
||||||
start: 0,
|
|
||||||
end: 5,
|
|
||||||
previousTechStep: null,
|
|
||||||
correctedTechStep: simmer,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
}).as("submitCorrection");
|
|
||||||
const onSubmitted = cy.stub().as("onSubmitted");
|
|
||||||
cy.mount(<Harness onSubmitted={onSubmitted} />);
|
|
||||||
cy.wait("@getTechSteps");
|
|
||||||
|
|
||||||
cy.contains(
|
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
||||||
"Mijoter",
|
|
||||||
).click();
|
|
||||||
cy.contains("button", "Valider").click();
|
|
||||||
|
|
||||||
cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
|
|
||||||
start: 0,
|
|
||||||
end: 5,
|
|
||||||
previousTechStepId: null,
|
|
||||||
correctedTechStepId: simmer.id,
|
|
||||||
});
|
|
||||||
cy.get("@onSubmitted").should("have.been.calledOnce");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds an ingredient with quantity/unit via the span-selection flow, included in the submitted request", () => {
|
|
||||||
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
|
||||||
statusCode: 201,
|
|
||||||
body: {
|
|
||||||
id: 1,
|
|
||||||
start: 0,
|
|
||||||
end: 5,
|
|
||||||
previousTechStep: null,
|
|
||||||
correctedTechStep: simmer,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
}).as("submitCorrection");
|
|
||||||
cy.mount(<Harness />);
|
|
||||||
cy.wait("@getTechSteps");
|
|
||||||
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
|
|
||||||
|
|
||||||
cy.contains(
|
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
||||||
"Mijoter",
|
|
||||||
).click();
|
|
||||||
cy.contains("button", "+ Ajouter un ingrédient").click();
|
|
||||||
|
|
||||||
cy.contains(".catalog-search-picker button", "Beurre").click();
|
|
||||||
cy.get('input[type="number"]').type("50");
|
|
||||||
cy.get("select").select(String(gram.id));
|
|
||||||
cy.contains("button", "Ajouter").click();
|
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").should("be.visible");
|
|
||||||
cy.contains("button", "Valider").click();
|
|
||||||
|
|
||||||
cy.wait("@submitCorrection")
|
|
||||||
.its("request.body")
|
|
||||||
.should("deep.equal", {
|
|
||||||
start: 0,
|
|
||||||
end: 5,
|
|
||||||
previousTechStepId: null,
|
|
||||||
correctedTechStepId: simmer.id,
|
|
||||||
ingredients: [
|
|
||||||
{ ingredientId: butter.id, quantity: 50, unitId: gram.id, start: 20, end: 26 },
|
|
||||||
],
|
|
||||||
utensils: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pre-seeds existing ingredients/utensils, removable via their own chip", () => {
|
|
||||||
cy.mount(
|
|
||||||
<Harness
|
|
||||||
previousTechStepId={cook.id}
|
|
||||||
existingIngredients={[
|
|
||||||
{ ingredient: butter, quantity: 50, unit: gram, start: 0, end: 6, source: "auto" },
|
|
||||||
]}
|
|
||||||
existingUtensils={[{ utensil: pan, start: 14, end: 23, source: "auto" }]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
// An existing match starts pre-selected on itself (see
|
|
||||||
// TechStepCorrectionPopover's own doc comment) — the metadata sections,
|
|
||||||
// pre-seeded from `existingIngredients`/`existingUtensils`, are visible
|
|
||||||
// immediately, no need to re-pick "Cuire" from a list first.
|
|
||||||
cy.wait("@getTechSteps");
|
|
||||||
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
|
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").find("button").click();
|
|
||||||
cy.contains(".tech-step-correction-popover__chip", "Beurre").should("not.exist");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows an error message and stays open when the submission fails", () => {
|
|
||||||
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
|
||||||
statusCode: 404,
|
|
||||||
body: { code: 4051, message: "TechStep not found" },
|
|
||||||
}).as("submitCorrection");
|
|
||||||
const onClose = cy.stub().as("onClose");
|
|
||||||
cy.mount(<Harness onClose={onClose} />);
|
|
||||||
cy.wait("@getTechSteps");
|
|
||||||
|
|
||||||
cy.contains(
|
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
||||||
"Cuire",
|
|
||||||
).click();
|
|
||||||
cy.contains("button", "Valider").click();
|
|
||||||
|
|
||||||
cy.wait("@submitCorrection");
|
|
||||||
cy.get(".field-error").should("be.visible");
|
|
||||||
cy.get("@onClose").should("not.have.been.called");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onClose on an outside click", () => {
|
|
||||||
const onClose = cy.stub().as("onClose");
|
|
||||||
cy.mount(<Harness onClose={onClose} />);
|
|
||||||
cy.wait("@getTechSteps");
|
|
||||||
|
|
||||||
cy.get('[data-testid="outside-popover"]').click();
|
|
||||||
|
|
||||||
cy.get("@onClose").should("have.been.calledOnce");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -6,20 +6,18 @@ import { splitDescriptionByTechSteps } from "../../src/features/recipes/steps/hi
|
||||||
// `expect`, not for rendering. `.cy.tsx` (not `.cy.ts`) only because that's
|
// `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). `source` defaults to `"auto"`, the common case every test not specifically about the manual/auto distinction uses. */
|
/** Builds a `StepTechStepView` — `context` omitted entirely (not just undefined) when absent, matching what the API actually sends for an older, not-yet-recomputed match (see `StepTechStepView`'s own doc comment). */
|
||||||
function techStep(
|
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 } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -27,7 +25,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, source: null },
|
{ text: "Servir immédiatement", techStep: null, isKeyword: false },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -39,25 +37,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, source: null },
|
{ text: "Faire ", techStep: null, isKeyword: false },
|
||||||
{ text: "mijoter", techStep: { id: 1, key: "simmer" }, isKeyword: true, source: "auto" },
|
{ text: "mijoter", techStep: { id: 1, key: "simmer" }, isKeyword: true },
|
||||||
{ text: " à feu doux", techStep: null, isKeyword: false, source: null },
|
{ text: " à feu doux", techStep: null, isKeyword: false },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
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, source: "auto" },
|
{ text: "Hacher", techStep: { id: 2, key: "chop" }, isKeyword: true },
|
||||||
{ text: " les oignons", techStep: null, isKeyword: false, source: null },
|
{ text: " les oignons", techStep: null, isKeyword: false },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
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, source: null },
|
{ text: "Faire ", techStep: null, isKeyword: false },
|
||||||
{ text: "cuire", techStep: { id: 3, key: "cook" }, isKeyword: true, source: "auto" },
|
{ text: "cuire", techStep: { id: 3, key: "cook" }, isKeyword: true },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -73,7 +71,6 @@ describe("splitDescriptionByTechSteps", () => {
|
||||||
text: "Préchauffer",
|
text: "Préchauffer",
|
||||||
techStep: { id: 4, key: "preheat" },
|
techStep: { id: 4, key: "preheat" },
|
||||||
isKeyword: true,
|
isKeyword: true,
|
||||||
source: "auto",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -91,23 +88,17 @@ 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([
|
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]);
|
||||||
{ 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([
|
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]);
|
||||||
{ 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([
|
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]);
|
||||||
{ 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", () => {
|
||||||
|
|
@ -119,7 +110,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, source: "auto" },
|
{ text: "Cuire au four", techStep: { id: 3, key: "bake" }, isKeyword: true },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -127,42 +118,6 @@ 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
|
||||||
|
|
@ -173,23 +128,12 @@ 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: "Dans une ",
|
{ text: "poêle chaude", techStep: { id: 4, key: "preheat" }, isKeyword: true },
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
@ -199,13 +143,8 @@ 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: "préchauffer",
|
{ text: " le four", techStep: { id: 4, key: "preheat" }, isKeyword: false },
|
||||||
techStep: { id: 4, key: "preheat" },
|
|
||||||
isKeyword: true,
|
|
||||||
source: "auto",
|
|
||||||
},
|
|
||||||
{ text: " le four", techStep: { id: 4, key: "preheat" }, isKeyword: false, source: "auto" },
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -214,19 +153,9 @@ 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, source: null },
|
{ text: "mettre ", techStep: null, isKeyword: false },
|
||||||
{
|
{ text: "le four à ", techStep: { id: 4, key: "preheat" }, isKeyword: false },
|
||||||
text: "le four à ",
|
{ text: "préchauffer", techStep: { id: 4, key: "preheat" }, isKeyword: true },
|
||||||
techStep: { id: 4, key: "preheat" },
|
|
||||||
isKeyword: false,
|
|
||||||
source: "auto",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "préchauffer",
|
|
||||||
techStep: { id: 4, key: "preheat" },
|
|
||||||
isKeyword: true,
|
|
||||||
source: "auto",
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -242,9 +171,7 @@ 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([
|
expect(result).to.deep.equal([{ text: "Cuire au four", techStep: null, isKeyword: false }]);
|
||||||
{ text: "Cuire au four", techStep: null, isKeyword: false, source: null },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -147,13 +147,11 @@ describe("Page width — full-bleed pages vs. centered reading columns (#21 regr
|
||||||
// that cap was dropped so they fill the width like every other page.
|
// 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");
|
||||||
|
});
|
||||||
|
|
||||||
cy.intercept("GET", /\/shopping-list\?/, {
|
it("centers the Liste de courses stub, with equal space on both sides", () => {
|
||||||
statusCode: 200,
|
|
||||||
body: { startDate: "2026-08-17", finishDate: "2026-08-23", items: [] },
|
|
||||||
});
|
|
||||||
cy.visit("/liste-de-courses");
|
cy.visit("/liste-de-courses");
|
||||||
assertFillsContentWidth(".shopping-list-page");
|
assertCenteredColumn(".coming-soon-page", 640); // max-width: 40rem
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Fills `.app-content`'s available (padding-excluded) width, within a couple px of scrollbar/rounding slack. */
|
/** Fills `.app-content`'s available (padding-excluded) width, within a couple px of scrollbar/rounding slack. */
|
||||||
|
|
@ -170,6 +168,22 @@ describe("Page width — full-bleed pages vs. centered reading columns (#21 regr
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Capped at `maxWidthPx` (not stretched full-bleed) and horizontally centered — equal left/right gap within `.app-content`. */
|
||||||
|
function assertCenteredColumn(selector: string, maxWidthPx: number) {
|
||||||
|
cy.get(".app-content").then(($content) => {
|
||||||
|
const contentRect = $content[0].getBoundingClientRect();
|
||||||
|
|
||||||
|
cy.get(selector).should(($page) => {
|
||||||
|
const pageRect = $page[0].getBoundingClientRect();
|
||||||
|
expect(pageRect.width).to.be.closeTo(maxWidthPx, 2);
|
||||||
|
|
||||||
|
const leftGap = pageRect.left - contentRect.left;
|
||||||
|
const rightGap = contentRect.right - pageRect.right;
|
||||||
|
expect(leftGap).to.be.closeTo(rightGap, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Responsive breakpoint — sidebar becomes a horizontal top bar under 640px", () => {
|
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 pages", () => {
|
it("highlights the current section and navigates between stub 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,29 +45,6 @@ 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,81 +83,6 @@ Given("browsing TheMealDB returns some items", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* A second, distinct item from `Given("browsing TheMealDB returns some
|
|
||||||
* items")`'s page-1 pair — used by the infinite-scroll/retry scenarios
|
|
||||||
* below, which need to tell "the item that only shows up once the *next*
|
|
||||||
* page has loaded" apart from what's already visible on page 1.
|
|
||||||
*/
|
|
||||||
const BEEF_WELLINGTON_ITEM = {
|
|
||||||
externalId: "77123",
|
|
||||||
title: "Beef Wellington",
|
|
||||||
picture: null,
|
|
||||||
url: "https://www.themealdb.com/meal/77123",
|
|
||||||
alreadyImported: false,
|
|
||||||
recipeId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CHICKEN_HANDI_AND_FISH_PIE_PAGE_1 = {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
externalId: "52795",
|
|
||||||
title: "Chicken Handi",
|
|
||||||
picture: null,
|
|
||||||
url: "https://www.themealdb.com/meal/52795",
|
|
||||||
alreadyImported: true,
|
|
||||||
recipeId: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
externalId: "9999",
|
|
||||||
title: "Fish Pie",
|
|
||||||
picture: null,
|
|
||||||
url: "https://www.themealdb.com/meal/9999",
|
|
||||||
alreadyImported: false,
|
|
||||||
recipeId: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
nextCursor: "2",
|
|
||||||
};
|
|
||||||
|
|
||||||
Given("browsing TheMealDB returns two pages of items", () => {
|
|
||||||
cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => {
|
|
||||||
const isNextPage = req.url.includes("cursor=");
|
|
||||||
req.reply({
|
|
||||||
statusCode: 200,
|
|
||||||
body: isNextPage
|
|
||||||
? { items: [BEEF_WELLINGTON_ITEM], nextCursor: null }
|
|
||||||
: CHICKEN_HANDI_AND_FISH_PIE_PAGE_1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The panel prefetches the next page as soon as page 1 is on screen (before
|
|
||||||
// anyone's actually waited on it), so the *first* request for it is that
|
|
||||||
// prefetch — this is what actually fails "once", not a request triggered by
|
|
||||||
// a click. `handleLoadMore`'s own retry then makes a genuinely fresh
|
|
||||||
// request (see its own doc comment on why a failed prefetch gets cleared),
|
|
||||||
// which is the one that succeeds here.
|
|
||||||
Given("browsing TheMealDB's next page fails once, then succeeds", () => {
|
|
||||||
let nextPageAttempts = 0;
|
|
||||||
cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => {
|
|
||||||
if (!req.url.includes("cursor=")) {
|
|
||||||
req.reply({ statusCode: 200, body: CHICKEN_HANDI_AND_FISH_PIE_PAGE_1 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
nextPageAttempts += 1;
|
|
||||||
if (nextPageAttempts === 1) {
|
|
||||||
req.reply({ statusCode: 500, body: {} });
|
|
||||||
} else {
|
|
||||||
req.reply({ statusCode: 200, body: { items: [BEEF_WELLINGTON_ITEM], nextCursor: null } });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("I should see a message to retry loading more", () => {
|
|
||||||
cy.contains(".recipes-page__status--error", "Réessayer").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
Given("previewing TheMealDB item {string} is available", (externalId: string) => {
|
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,24 +27,6 @@ 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, source: "auto" }],
|
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
@ -68,84 +68,6 @@ Given("deleting recipe 2 will succeed", () => {
|
||||||
cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe");
|
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");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
Feature: Shopping list
|
|
||||||
As a signed-in user
|
|
||||||
I want to see every ingredient needed for this week's planned recipes, already summed
|
|
||||||
So that I know what to buy without recomputing it myself
|
|
||||||
|
|
||||||
Background:
|
|
||||||
Given I am signed in as "Alice" "Martin"
|
|
||||||
And today is frozen at "2026-08-17T09:00:00.000Z"
|
|
||||||
|
|
||||||
Scenario: Nothing planned this week shows the empty message, not an error
|
|
||||||
Given the shopping list for "2026-08-17" is empty
|
|
||||||
When I visit "/liste-de-courses"
|
|
||||||
Then I should see "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning."
|
|
||||||
|
|
||||||
Scenario: Ingredients are grouped by aisle, in canonical order, each with its summed quantity
|
|
||||||
Given the shopping list for "2026-08-17" contains:
|
|
||||||
| ingredientKey | icon | category | quantity | unitKey |
|
|
||||||
| egg | EGG | dairyAndCheese | 6 | piece |
|
|
||||||
| tomato | VEGETABLE | freshProduce | 400 | gram |
|
|
||||||
When I visit "/liste-de-courses"
|
|
||||||
Then the shopping list group "Produits frais" should appear before "Crémerie & fromage"
|
|
||||||
And the shopping list should show "Tomate" at quantity "400 g"
|
|
||||||
And the shopping list should show "Oeuf" at quantity "6 unité"
|
|
||||||
|
|
||||||
Scenario: Navigating to another week fetches and shows that week's own list
|
|
||||||
Given the shopping list for "2026-08-17" is empty
|
|
||||||
And the shopping list for "2026-08-24" contains:
|
|
||||||
| ingredientKey | icon | category | quantity | unitKey |
|
|
||||||
| onion | VEGETABLE | freshProduce | 1 | kilogram |
|
|
||||||
When I visit "/liste-de-courses"
|
|
||||||
And I click the next week arrow
|
|
||||||
Then the shopping list should show "Oignon" at quantity "1 kg"
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import { type DataTable, Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One data-table row → a fake `ShoppingListItemView` — same minimal-fixture
|
|
||||||
* convention as `recipe-form.ts`'s ingredient fixtures (only the fields
|
|
||||||
* `ShoppingListPage` actually reads at runtime: the ingredient's `key`/
|
|
||||||
* `icon`/`category` for `IngredientTypeIcon`/`CategoryIcon`/translation, the
|
|
||||||
* unit's `key`; `id` only needs to be unique per row for the React list
|
|
||||||
* key). `index` seeds both ids so two rows never collide.
|
|
||||||
*/
|
|
||||||
function buildShoppingListItem(
|
|
||||||
row: { ingredientKey: string; icon: string; category: string; quantity: string; unitKey: string },
|
|
||||||
index: number,
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
ingredient: {
|
|
||||||
id: index,
|
|
||||||
key: row.ingredientKey,
|
|
||||||
icon: row.icon,
|
|
||||||
category: row.category,
|
|
||||||
subcategory: row.category,
|
|
||||||
reproducible: false,
|
|
||||||
allergens: [],
|
|
||||||
diets: [],
|
|
||||||
},
|
|
||||||
quantity: Number(row.quantity),
|
|
||||||
unit: { id: index, key: row.unitKey, type: "MASS", toBaseFactor: 1 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Given("the shopping list for {string} is empty", (date: string) => {
|
|
||||||
cy.intercept("GET", `**/shopping-list?date=${date}`, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { startDate: date, finishDate: date, items: [] },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
Given("the shopping list for {string} contains:", (date: string, dataTable: DataTable) => {
|
|
||||||
const items = dataTable.hashes().map((row, i) => buildShoppingListItem(row, i + 1));
|
|
||||||
cy.intercept("GET", `**/shopping-list?date=${date}`, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { startDate: date, finishDate: date, items },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Same class as PlanningPage's own week navigator (`WeekNavigator`, now
|
|
||||||
// shared between the two pages) — `planning-page.cy.ts` already exercises
|
|
||||||
// the prev/next arrows directly by class, same approach here.
|
|
||||||
When("I click the next week arrow", () => {
|
|
||||||
cy.get(".week-nav__arrow").last().click();
|
|
||||||
});
|
|
||||||
|
|
||||||
Then(
|
|
||||||
"the shopping list group {string} should appear before {string}",
|
|
||||||
(first: string, second: string) => {
|
|
||||||
cy.get(".shopping-list__group-title").then(($titles) => {
|
|
||||||
const texts = [...$titles].map((el) => el.textContent?.trim() ?? "");
|
|
||||||
const firstIndex = texts.findIndex((text) => text.includes(first));
|
|
||||||
const secondIndex = texts.findIndex((text) => text.includes(second));
|
|
||||||
expect(firstIndex, `"${first}" should be a rendered group`).to.be.greaterThan(-1);
|
|
||||||
expect(secondIndex, `"${second}" should be a rendered group`).to.be.greaterThan(-1);
|
|
||||||
expect(firstIndex).to.be.lessThan(secondIndex);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Then(
|
|
||||||
"the shopping list should show {string} at quantity {string}",
|
|
||||||
(name: string, quantity: string) => {
|
|
||||||
cy.contains(".shopping-list__item", name).should("contain.text", quantity);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -40,21 +40,6 @@ 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,17 +17,11 @@ 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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -165,18 +159,6 @@ 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");
|
||||||
|
|
@ -197,16 +179,6 @@ 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");
|
||||||
|
|
@ -295,26 +267,6 @@ 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");
|
||||||
|
|
|
||||||
17
apps/web/src/components/ui/ComingSoonPage.scss
Normal file
17
apps/web/src/components/ui/ComingSoonPage.scss
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// =============================================================================
|
||||||
|
// Styles for ComingSoonPage — shared by every stub section page.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Centered, not pinned to `.app-content`'s left edge — same reasoning as
|
||||||
|
// `.settings-page` (settings-pages.scss): on a wide desktop viewport a
|
||||||
|
// left-aligned `max-width` here just left a lopsided gap down the right
|
||||||
|
// side instead of framing the placeholder copy.
|
||||||
|
.coming-soon-page {
|
||||||
|
max-width: 40rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/web/src/components/ui/ComingSoonPage.tsx
Normal file
26
apps/web/src/components/ui/ComingSoonPage.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import "./ComingSoonPage.scss";
|
||||||
|
|
||||||
|
interface ComingSoonPageProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder rendered by a section that has a route/sidebar entry but no
|
||||||
|
* real feature behind it yet — today only `pages/shopping-list/ShoppingListPage.tsx`
|
||||||
|
* (`Recettes`/`Foyer & profil` both grew real backends since this was
|
||||||
|
* written, see `pages/recipes/`/`pages/settings/`). Kept as a shared,
|
||||||
|
* reusable component (`components/ui/`, not itself a routed page) rather
|
||||||
|
* than inlined into that one page, so a future stub section doesn't need to
|
||||||
|
* hand-roll the same markup — the page that needs it still gets its own
|
||||||
|
* file (and its own copy, via i18n), just wrapping this instead of
|
||||||
|
* rewriting it.
|
||||||
|
*/
|
||||||
|
export function ComingSoonPage({ title, description }: ComingSoonPageProps) {
|
||||||
|
return (
|
||||||
|
<div className="coming-soon-page">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<p>{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
import {
|
|
||||||
addWeeks,
|
|
||||||
buildCalendarMonth,
|
|
||||||
DateTime,
|
|
||||||
getWeekStart,
|
|
||||||
toDateOnly,
|
|
||||||
} from "@batch-cooking/date-tools";
|
|
||||||
import { WEEK_DAYS } from "@batch-cooking/shared";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import "./week-navigator.scss";
|
|
||||||
|
|
||||||
/** "17 au 23 août 2026" — collapses the month/year to just the end date when both ends of the week share it, spells it out on both ends otherwise (e.g. a week straddling two months). */
|
|
||||||
function formatWeekRange(weekStart: DateTime): string {
|
|
||||||
const weekEnd = weekStart.plus({ days: 6 });
|
|
||||||
const sameMonth = weekStart.hasSame(weekEnd, "month");
|
|
||||||
const startLabel = weekStart.toLocaleString(
|
|
||||||
sameMonth ? { day: "numeric" } : { day: "numeric", month: "long" },
|
|
||||||
{ locale: "fr" },
|
|
||||||
);
|
|
||||||
const endLabel = weekEnd.toLocaleString(
|
|
||||||
{ day: "numeric", month: "long", year: "numeric" },
|
|
||||||
{ locale: "fr" },
|
|
||||||
);
|
|
||||||
return `${startLabel} au ${endLabel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Arrows + clickable label opening {@link CalendarPopover} — week-selection
|
|
||||||
* UI shared by any page organized around "one week at a time" (originally
|
|
||||||
* `PlanningPage`'s own grid, now also `ShoppingListPage` — both just need a
|
|
||||||
* `weekStart` in/out, neither cares how the other renders its own content
|
|
||||||
* for that week). Copy comes from `common.weekNav.*`/`common.calendar.*`/
|
|
||||||
* `common.days.*` rather than `planning.*` — generic enough ("Semaine
|
|
||||||
* précédente", day names) to not read as planning-specific from a page that
|
|
||||||
* isn't the planning grid.
|
|
||||||
*/
|
|
||||||
export function WeekNavigator({
|
|
||||||
weekStart,
|
|
||||||
onChangeWeek,
|
|
||||||
}: {
|
|
||||||
weekStart: DateTime;
|
|
||||||
onChangeWeek: (weekStart: DateTime) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
|
||||||
const isThisWeek = weekStart.hasSame(getWeekStart(DateTime.utc()), "day");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="week-nav">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="week-nav__arrow"
|
|
||||||
title={t("common.weekNav.prevWeek")}
|
|
||||||
onClick={() => onChangeWeek(addWeeks(weekStart, -1))}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="week-nav__label"
|
|
||||||
onClick={() => setIsCalendarOpen((open) => !open)}
|
|
||||||
>
|
|
||||||
📅 {t("common.weekNav.label", { range: formatWeekRange(weekStart) })}
|
|
||||||
{isThisWeek && <span className="today-badge">{t("common.weekNav.thisWeek")}</span>}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="week-nav__arrow"
|
|
||||||
title={t("common.weekNav.nextWeek")}
|
|
||||||
onClick={() => onChangeWeek(addWeeks(weekStart, 1))}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isCalendarOpen && (
|
|
||||||
<CalendarPopover
|
|
||||||
selectedWeekStart={weekStart}
|
|
||||||
onSelectDay={(day) => {
|
|
||||||
onChangeWeek(getWeekStart(day));
|
|
||||||
setIsCalendarOpen(false);
|
|
||||||
}}
|
|
||||||
onClose={() => setIsCalendarOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Month calendar letting the visitor jump to any week at once — selecting a day selects its whole (Monday-first) week. Closes itself on an outside click. */
|
|
||||||
function CalendarPopover({
|
|
||||||
selectedWeekStart,
|
|
||||||
onSelectDay,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
selectedWeekStart: DateTime;
|
|
||||||
onSelectDay: (day: DateTime) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
// Its own state: browsing to a different month to pick a week there
|
|
||||||
// shouldn't jump back every render — only re-anchors when the popover is
|
|
||||||
// first opened (`selectedWeekStart` at that point), not while it's open.
|
|
||||||
const [visibleMonth, setVisibleMonth] = useState(() => selectedWeekStart.startOf("month"));
|
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const today = toDateOnly(DateTime.utc());
|
|
||||||
const selectedWeekEnd = selectedWeekStart.plus({ days: 6 });
|
|
||||||
const weeks = buildCalendarMonth(visibleMonth);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="calendar-popover" ref={popoverRef}>
|
|
||||||
<div className="calendar-popover__header">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={t("common.calendar.prevMonth")}
|
|
||||||
onClick={() => setVisibleMonth((month) => month.minus({ months: 1 }))}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<span>
|
|
||||||
{visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={t("common.calendar.nextMonth")}
|
|
||||||
onClick={() => setVisibleMonth((month) => month.plus({ months: 1 }))}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="calendar-grid">
|
|
||||||
{WEEK_DAYS.map((weekDay) => (
|
|
||||||
<span key={weekDay} className="calendar-grid__weekday">
|
|
||||||
{t(`common.days.${weekDay}`).charAt(0)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{weeks.flat().map((day) => {
|
|
||||||
const classNames = ["calendar-grid__day"];
|
|
||||||
if (!day.hasSame(visibleMonth, "month")) classNames.push("calendar-grid__day--muted");
|
|
||||||
if (day >= selectedWeekStart && day <= selectedWeekEnd) {
|
|
||||||
classNames.push("calendar-grid__day--in-selected-week");
|
|
||||||
}
|
|
||||||
if (day.hasSame(today, "day")) classNames.push("calendar-grid__day--today");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={day.toISO()}
|
|
||||||
type="button"
|
|
||||||
className={classNames.join(" ")}
|
|
||||||
onClick={() => onSelectDay(day)}
|
|
||||||
>
|
|
||||||
{day.day}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
// =============================================================================
|
|
||||||
// Styles for WeekNavigator.tsx (arrows + label + calendar popover) —
|
|
||||||
// colocated next to the component since nothing else uses these classes.
|
|
||||||
// Extracted from planning-page.scss once ShoppingListPage started reusing
|
|
||||||
// the component — same design tokens, no light/dark duplication needed
|
|
||||||
// (every `var(--color-*)` below already resolves per-theme globally, see
|
|
||||||
// styles/_theme.scss).
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
.week-nav {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
|
|
||||||
&__arrow {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
padding: 0.45rem var(--space-md);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-badge {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-primary);
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Calendar popover -------------------------------------------------------
|
|
||||||
.calendar-popover {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + var(--space-xs));
|
|
||||||
right: 0;
|
|
||||||
z-index: 10;
|
|
||||||
width: 18rem;
|
|
||||||
padding: var(--space-md);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
|
|
||||||
&__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
text-transform: capitalize;
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 1.6rem;
|
|
||||||
height: 1.6rem;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, 1fr);
|
|
||||||
gap: 2px;
|
|
||||||
|
|
||||||
&__weekday {
|
|
||||||
text-align: center;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
padding-bottom: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__day {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-text);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
font: inherit;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--muted {
|
|
||||||
color: var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--in-selected-week {
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--today {
|
|
||||||
box-shadow: inset 0 0 0 2px var(--color-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -205,34 +205,11 @@ 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="" />}
|
||||||
{/* Tied to `showActions` (not unconditionally on): that flag
|
<StepDescription description={step.description} techSteps={step.techSteps} />
|
||||||
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,75 +372,20 @@
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Browsing a source scrolls infinitely (SourceItemTable's own sentinel row
|
.source-items-load-more {
|
||||||
// triggers RecipeSourcesPanel's handleLoadMore) — this is only the inline
|
flex-shrink: 0;
|
||||||
// retry action shown alongside recipes.sources.loadMoreError when a page
|
align-self: center;
|
||||||
// fetch actually fails, not a persistent "load more" control.
|
padding: 0.4rem var(--space-lg);
|
||||||
.source-items-retry {
|
font-family: var(--font-body);
|
||||||
padding: 0;
|
font-size: var(--font-size-sm);
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: 1px solid var(--color-border);
|
||||||
text-decoration: underline;
|
border-radius: var(--radius-pill);
|
||||||
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 {
|
||||||
background: none;
|
border-color: var(--color-primary);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -682,273 +627,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A `"manual"`-sourced match (a viewer's correction, applied immediately —
|
|
||||||
// see `StepTechStepView.source`) — same shape as `.step-tech-step`, but in
|
|
||||||
// `--color-tag` (Turmeric) instead of `--color-primary` (Basil), so the two
|
|
||||||
// origins are distinguishable at a glance, not just via the tooltip text.
|
|
||||||
.step-tech-step--manual {
|
|
||||||
background: color-mix(in srgb, var(--color-tag) 18%, transparent);
|
|
||||||
text-decoration-color: var(--color-tag);
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus-visible {
|
|
||||||
background: color-mix(in srgb, var(--color-tag) 28%, transparent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `.step-tech-step-context` (the wider clause a `.step-tech-step` keyword
|
// `.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, 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 { RecipeDetailPanel, type RecipeDetailState } from "../RecipeDetailPanel";
|
import { RecipeDetailPanel, type RecipeDetailState } from "../RecipeDetailPanel";
|
||||||
|
|
@ -9,15 +9,6 @@ import "../recipes.scss";
|
||||||
/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */
|
/** 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;
|
||||||
|
|
@ -85,30 +76,6 @@ 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,
|
||||||
);
|
);
|
||||||
|
|
@ -160,11 +127,6 @@ 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 })
|
||||||
|
|
@ -180,78 +142,21 @@ 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;
|
||||||
const query = debouncedSearch.trim();
|
apiClient
|
||||||
setLoadMoreStatus("loading");
|
.browseSource(sourceKey, { query: debouncedSearch.trim() || undefined, cursor })
|
||||||
|
|
||||||
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(() => {
|
.catch(() => setBrowseState({ status: "error" }));
|
||||||
// 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) {
|
||||||
|
|
@ -312,17 +217,11 @@ 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}
|
|
||||||
/>
|
/>
|
||||||
{loadMoreStatus === "error" && (
|
{browseState.nextCursor && (
|
||||||
<p className="recipes-page__status recipes-page__status--error">
|
<button type="button" className="source-items-load-more" onClick={handleLoadMore}>
|
||||||
{t("recipes.sources.loadMoreError")}{" "}
|
{t("recipes.sources.loadMore")}
|
||||||
<button type="button" className="source-items-retry" onClick={handleLoadMore}>
|
</button>
|
||||||
{t("recipes.sources.retry")}
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -9,66 +8,20 @@ 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" ref={scrollContainerRef}>
|
<div className="recipe-table-wrap">
|
||||||
<table className="recipe-table">
|
<table className="recipe-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -107,23 +60,6 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Small search-and-pick list — a lighter alternative to `IngredientPicker.tsx`
|
|
||||||
* (category/subcategory grid + allergen/diet toggles) for a context that
|
|
||||||
* doesn't have room for that: `TechStepCorrectionPopover.tsx`'s ingredient/
|
|
||||||
* utensil/**technique** pickers, all embedded in a small popover rather than
|
|
||||||
* a full recipe form. Reused for all three — an ingredient, a utensil, and a
|
|
||||||
* technique are all "search a reference list by translated label, pick one"
|
|
||||||
* from this component's point of view, the only difference is which
|
|
||||||
* `items`/labels the caller passes in. The technique catalog in particular
|
|
||||||
* (~74 entries) is exactly the case a plain unfiltered list stops being
|
|
||||||
* readable at — the original motivation for adding search here at all.
|
|
||||||
*
|
|
||||||
* Deliberately just `{ id, label }` in, `id` out — no `IngredientView`/
|
|
||||||
* `UtensilView`/`TechStepView` dependency here, so this stays reusable for
|
|
||||||
* any future "search this small reference catalog" need without growing a
|
|
||||||
* new prop per catalog shape.
|
|
||||||
*/
|
|
||||||
export function CatalogSearchPicker({
|
|
||||||
items,
|
|
||||||
selectedId,
|
|
||||||
onSelect,
|
|
||||||
placeholder,
|
|
||||||
emptyLabel,
|
|
||||||
}: {
|
|
||||||
items: { id: number; label: string }[];
|
|
||||||
/** The currently-picked item, if any — marked with a distinct modifier class so it stays visible at a glance while browsing/filtering a longer list (e.g. `TechStepCorrectionPopover`'s ~74-entry technique catalog), not just implied by whatever's selected elsewhere on screen. Omit for a picker with no notion of a "current" pick (the ingredient/utensil span sub-flows — each `onSelect` there just appends a brand-new mention, nothing to mark as already chosen). */
|
|
||||||
selectedId?: number;
|
|
||||||
onSelect: (id: number) => void;
|
|
||||||
placeholder: string;
|
|
||||||
emptyLabel: string;
|
|
||||||
}) {
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
|
||||||
const visible =
|
|
||||||
normalizedQuery.length === 0
|
|
||||||
? items
|
|
||||||
: items.filter((item) => item.label.toLowerCase().includes(normalizedQuery));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="catalog-search-picker">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className="catalog-search-picker__input"
|
|
||||||
/>
|
|
||||||
{visible.length === 0 ? (
|
|
||||||
<p className="catalog-search-picker__empty">{emptyLabel}</p>
|
|
||||||
) : (
|
|
||||||
<ul className="catalog-search-picker__list">
|
|
||||||
{visible.map((item) => (
|
|
||||||
<li key={item.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={
|
|
||||||
item.id === selectedId ? "catalog-search-picker__item--selected" : undefined
|
|
||||||
}
|
|
||||||
onClick={() => onSelect(item.id)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import type { StepTechStepView, SubmitTechStepCorrectionResult } from "@batch-cooking/shared";
|
import type { StepTechStepView } from "@batch-cooking/shared";
|
||||||
import { Fragment, useEffect, useRef, useState } from "react";
|
import { Fragment } 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
|
||||||
|
|
@ -25,222 +23,48 @@ import { type TextSelectionRange, useTextSelection } from "./use-text-selection"
|
||||||
*
|
*
|
||||||
* `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. A keyword's `source` (`"auto"` — the
|
* uses for its display text.
|
||||||
* 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 [liveTechSteps, setLiveTechSteps] = useState(techSteps);
|
const segments = splitDescriptionByTechSteps(description, 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>
|
||||||
<p ref={containerRef} onMouseUp={handleMouseUp}>
|
{segments.map((segment, index) => {
|
||||||
{segments.map((segment, index) => {
|
// A segment's own text/techStep don't uniquely identify it (the
|
||||||
const start = offset;
|
// same word can appear twice in one description) — index is the
|
||||||
offset += segment.text.length;
|
// only thing that does, but this list is fully regenerated from
|
||||||
// Captured now, not read as `offset` later inside a click
|
// `description`/`techSteps` on every render (never reordered or
|
||||||
// handler below — `offset` keeps mutating for every subsequent
|
// spliced in place), so using it as part of the key is safe here.
|
||||||
// segment this same `.map()` pass renders, so a closure
|
const key = `${index}-${segment.text}`;
|
||||||
// referencing it directly would see its *final* value (the end
|
if (!segment.techStep) return <Fragment key={key}>{segment.text}</Fragment>;
|
||||||
// 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.techStep || !segment.isKeyword) {
|
if (!segment.isKeyword) {
|
||||||
// Context-only or plain run — rendered as plain text in
|
// Context-only run — rendered as plain text, same as a segment
|
||||||
// read-only mode, same as before this component supported
|
// with no technique at all (see this component's doc comment for
|
||||||
// `editable` at all (see this component's doc comment for why
|
// why the wider-clause highlight was turned back off).
|
||||||
// the wider-clause highlight itself was turned back off).
|
return <Fragment key={key}>{segment.text}</Fragment>;
|
||||||
// Editable mode still wraps it in a `data-offset` span so a
|
}
|
||||||
// selection starting/ending in plain text resolves correctly.
|
return (
|
||||||
if (!editable) return <Fragment key={key}>{segment.text}</Fragment>;
|
<Tooltip key={key} content={t(`catalog.techSteps.${segment.techStep.key}`)}>
|
||||||
return (
|
{/* A real <button>, not a <mark>, so it's natively focusable
|
||||||
<span key={key} data-offset={start}>
|
(keyboard/screen-reader users can reach the tooltip) without
|
||||||
{segment.text}
|
fighting the "non-interactive element" a11y lint a bare
|
||||||
</span>
|
tabIndex on <mark> would trip — styled to read as inline
|
||||||
);
|
highlighted text, not as a button (see .step-tech-step). */}
|
||||||
}
|
<button type="button" className="step-tech-step">
|
||||||
|
{segment.text}
|
||||||
const techStep = segment.techStep;
|
</button>
|
||||||
const isManual = segment.source === "manual";
|
</Tooltip>
|
||||||
const tooltipLabel = isManual
|
);
|
||||||
? t("recipes.techStepCorrection.manualTooltip", {
|
})}
|
||||||
technique: t(`catalog.techSteps.${techStep.key}`),
|
</p>
|
||||||
})
|
|
||||||
: 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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,522 +0,0 @@
|
||||||
import {
|
|
||||||
ErrorCode,
|
|
||||||
type IngredientView,
|
|
||||||
type StepTechStepIngredientView,
|
|
||||||
type StepTechStepUtensilView,
|
|
||||||
type SubmitTechStepCorrectionResult,
|
|
||||||
type TechStepView,
|
|
||||||
type UnitView,
|
|
||||||
type UtensilView,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ApiError, apiClient } from "../../../api/client";
|
|
||||||
import { errorMessageService } from "../../../services/error-message.service";
|
|
||||||
import { CatalogSearchPicker } from "./CatalogSearchPicker";
|
|
||||||
import type { TextSelectionRange } from "./use-text-selection";
|
|
||||||
|
|
||||||
/** One ingredient the viewer has attached (or is about to submit) — the trimmed-down shape `POST .../corrections`'s `ingredients[]` expects, kept separately from `StepTechStepIngredientView` since a pending one has no resolved `IngredientView`/`UnitView` to carry yet, only ids. */
|
|
||||||
interface PendingIngredient {
|
|
||||||
ingredientId: number;
|
|
||||||
quantity: number | null;
|
|
||||||
unitId: number | null;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
/** Same as {@link PendingIngredient}, for a utensil (no quantity/unit — nothing to measure). */
|
|
||||||
interface PendingUtensil {
|
|
||||||
utensilId: number;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toPendingIngredient(view: StepTechStepIngredientView): PendingIngredient {
|
|
||||||
return {
|
|
||||||
ingredientId: view.ingredient.id,
|
|
||||||
quantity: view.quantity,
|
|
||||||
unitId: view.unit?.id ?? null,
|
|
||||||
start: view.start,
|
|
||||||
end: view.end,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
|
|
||||||
return { utensilId: view.utensil.id, start: view.start, end: view.end };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Small non-modal popover letting a viewer assign a technique to a selected
|
|
||||||
* span of a step's description, or clear/relabel an existing match —
|
|
||||||
* opened by `StepDescription`'s editable mode. Same `mousedown`-outside-
|
|
||||||
* close pattern as `PlanningPage`'s `CalendarPopover`, not `Dialog.tsx`'s
|
|
||||||
* native `<dialog>` — this is a small, contextual pick-one-option surface,
|
|
||||||
* not a page-blocking modal.
|
|
||||||
*
|
|
||||||
* Rendered inline right below the step's own description block (see
|
|
||||||
* `StepDescription.tsx`), not floating anchored at the selection's exact
|
|
||||||
* position — simpler and more robust than tracking a caret-anchored
|
|
||||||
* position across scroll/resize, at the cost of a little visual distance
|
|
||||||
* from the selected text itself. That placement matters beyond cosmetics
|
|
||||||
* here: it's *why* the "attach an ingredient/utensil" flow below can ask
|
|
||||||
* the viewer to select a second span of text without closing this popover
|
|
||||||
* first — the description stays fully visible and selectable the whole
|
|
||||||
* time, nothing overlays it.
|
|
||||||
*
|
|
||||||
* **One merged editor, not a wizard**: picking a technique
|
|
||||||
* (`CatalogSearchPicker`, searchable — the reference catalog is ~74
|
|
||||||
* entries, an unfiltered flat list wasn't browsable) and editing its
|
|
||||||
* Ingrédients/Ustensiles metadata render together on the same screen,
|
|
||||||
* always — there's no separate "pick, then a metadata step reveals
|
|
||||||
* itself" sequence to go through, and no dead end where metadata is
|
|
||||||
* technically attachable but not visible until some other action happens
|
|
||||||
* first. A single "Valider" submits everything at once; disabled until a
|
|
||||||
* technique is actually selected (there's nothing to attach metadata to
|
|
||||||
* otherwise). **Removing** a match (`submit(null)`) stays its own
|
|
||||||
* immediate action next to the picker — nothing to attach when removing.
|
|
||||||
*
|
|
||||||
* The two metadata sections are pre-seeded from `existingIngredients`/
|
|
||||||
* `existingUtensils` (whatever's already attached to this occurrence, auto-
|
|
||||||
* or manually-sourced — `[]` for a brand-new technique) and editable via
|
|
||||||
* add/remove — see `metadataTouched` below for why what's *displayed* here
|
|
||||||
* isn't automatically what gets *submitted*.
|
|
||||||
*/
|
|
||||||
export function TechStepCorrectionPopover({
|
|
||||||
recipeId,
|
|
||||||
stepId,
|
|
||||||
selectedText,
|
|
||||||
range,
|
|
||||||
previousTechStepId,
|
|
||||||
existingIngredients,
|
|
||||||
existingUtensils,
|
|
||||||
resolvedMetadataSpan,
|
|
||||||
onRequestSpan,
|
|
||||||
onClose,
|
|
||||||
onSubmitted,
|
|
||||||
}: {
|
|
||||||
recipeId: number;
|
|
||||||
stepId: number;
|
|
||||||
/** The selected span's own text — shown so the user confirms what they're tagging before picking a technique. */
|
|
||||||
selectedText: string;
|
|
||||||
range: TextSelectionRange;
|
|
||||||
/** Set when correcting an already-detected match (opened from clicking its highlight) rather than a fresh selection — passed through as-is on submit, and offers a "remove" option `null` doesn't. */
|
|
||||||
previousTechStepId: number | null;
|
|
||||||
/** Whatever ingredients/utensils already sit on this occurrence (both `"auto"` and `"manual"` sourced) — `[]` for a brand-new technique, nothing to pre-seed. */
|
|
||||||
existingIngredients: StepTechStepIngredientView[];
|
|
||||||
existingUtensils: StepTechStepUtensilView[];
|
|
||||||
/**
|
|
||||||
* A text span `StepDescription` just resolved on this popover's behalf,
|
|
||||||
* after a call to `onRequestSpan` below — `null` until then. Identified
|
|
||||||
* by `nonce` (not by value) so this popover's own `useEffect` reliably
|
|
||||||
* fires once per fresh selection, even if the exact same span is
|
|
||||||
* selected twice in a row.
|
|
||||||
*/
|
|
||||||
resolvedMetadataSpan: {
|
|
||||||
nonce: number;
|
|
||||||
kind: "ingredient" | "utensil";
|
|
||||||
range: TextSelectionRange;
|
|
||||||
text: string;
|
|
||||||
} | null;
|
|
||||||
/** Tells `StepDescription` "the next text selection in the description is for an ingredient/utensil mention, not a new technique correction" — see this component's own doc comment. */
|
|
||||||
onRequestSpan: (kind: "ingredient" | "utensil") => void;
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmitted: (result: SubmitTechStepCorrectionResult) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
|
|
||||||
// Opening this popover on an *already-detected* match (`previousTechStepId
|
|
||||||
// !== null`, i.e. the user clicked an existing highlight rather than
|
|
||||||
// selecting fresh text) starts pre-selected on that same technique, its
|
|
||||||
// name shown next to the picker right away — since the picker and the
|
|
||||||
// metadata sections render together regardless (see this component's own
|
|
||||||
// doc comment), this just saves re-picking the technique that's already
|
|
||||||
// correct before its metadata becomes editable.
|
|
||||||
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
|
|
||||||
const [catalogs, setCatalogs] = useState<{
|
|
||||||
ingredients: IngredientView[];
|
|
||||||
units: UnitView[];
|
|
||||||
utensils: UtensilView[];
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const [pendingIngredients, setPendingIngredients] = useState<PendingIngredient[]>(() =>
|
|
||||||
existingIngredients.map(toPendingIngredient),
|
|
||||||
);
|
|
||||||
const [pendingUtensils, setPendingUtensils] = useState<PendingUtensil[]>(() =>
|
|
||||||
existingUtensils.map(toPendingUtensil),
|
|
||||||
);
|
|
||||||
// Flips true the moment the viewer adds/removes a pending entry — never
|
|
||||||
// from the initial seeding above. `submit()` below only includes
|
|
||||||
// `ingredients`/`utensils` in the request when this is true, so
|
|
||||||
// relabeling/confirming a technique without ever opening either section
|
|
||||||
// leaves existing metadata completely alone server-side (see
|
|
||||||
// `submitTechStepCorrectionSchema`'s own doc comment, `packages/shared`,
|
|
||||||
// for why an *omitted* field — not an empty array — is what "don't
|
|
||||||
// touch it" means over the wire).
|
|
||||||
const [metadataTouched, setMetadataTouched] = useState(false);
|
|
||||||
|
|
||||||
const [awaitingSpanFor, setAwaitingSpanFor] = useState<"ingredient" | "utensil" | null>(null);
|
|
||||||
const [activeSpan, setActiveSpan] = useState<{
|
|
||||||
kind: "ingredient" | "utensil";
|
|
||||||
range: TextSelectionRange;
|
|
||||||
text: string;
|
|
||||||
} | null>(null);
|
|
||||||
// Only meaningful while `activeSpan?.kind === "ingredient"` — the
|
|
||||||
// ingredient sub-flow is itself two steps (pick the ingredient, then its
|
|
||||||
// quantity/unit), this is where the first step's choice waits until the
|
|
||||||
// second is confirmed.
|
|
||||||
const [pickedIngredientId, setPickedIngredientId] = useState<number | null>(null);
|
|
||||||
const [spanQuantity, setSpanQuantity] = useState("");
|
|
||||||
const [spanUnitId, setSpanUnitId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
apiClient
|
|
||||||
.getTechSteps()
|
|
||||||
.then((list) => {
|
|
||||||
if (!cancelled) setTechSteps(list);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setTechSteps([]);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetched unconditionally on mount — the Ingrédients/Ustensiles sections
|
|
||||||
// render alongside the technique picker from the start (see this
|
|
||||||
// component's own doc comment on the merged editor), so there's no later
|
|
||||||
// point to defer this to anymore.
|
|
||||||
useEffect(() => {
|
|
||||||
if (catalogs !== null) return;
|
|
||||||
let cancelled = false;
|
|
||||||
Promise.all([apiClient.getIngredients(), apiClient.getUnits(), apiClient.getUtensils()])
|
|
||||||
.then(([ingredients, units, utensils]) => {
|
|
||||||
if (!cancelled) setCatalogs({ ingredients, units, utensils });
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setCatalogs({ ingredients: [], units: [], utensils: [] });
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [catalogs]);
|
|
||||||
|
|
||||||
// Consumes a span `StepDescription` just resolved on this popover's
|
|
||||||
// behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the
|
|
||||||
// matching sub-picker and clears the "awaiting a selection" hint.
|
|
||||||
useEffect(() => {
|
|
||||||
if (resolvedMetadataSpan === null) return;
|
|
||||||
setActiveSpan({
|
|
||||||
kind: resolvedMetadataSpan.kind,
|
|
||||||
range: resolvedMetadataSpan.range,
|
|
||||||
text: resolvedMetadataSpan.text,
|
|
||||||
});
|
|
||||||
setAwaitingSpanFor(null);
|
|
||||||
setPickedIngredientId(null);
|
|
||||||
setSpanQuantity("");
|
|
||||||
setSpanUnitId(null);
|
|
||||||
// Depends on the whole object, not just `.nonce` — `StepDescription`
|
|
||||||
// only ever calls its setter with a brand-new object (never mutates
|
|
||||||
// one in place), so reference equality alone already gives this the
|
|
||||||
// "fires once per fresh selection" behavior `nonce` documents, with no
|
|
||||||
// need to silence the exhaustive-deps lint to get there.
|
|
||||||
}, [resolvedMetadataSpan]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
async function removeMatch() {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const result = await apiClient.submitTechStepCorrection(recipeId, stepId, {
|
|
||||||
start: range.start,
|
|
||||||
end: range.end,
|
|
||||||
previousTechStepId,
|
|
||||||
correctedTechStepId: null,
|
|
||||||
});
|
|
||||||
onSubmitted(result);
|
|
||||||
onClose();
|
|
||||||
} catch (err) {
|
|
||||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
|
||||||
setError(errorMessageService.getLabel(code));
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirm() {
|
|
||||||
if (selectedTechStepId === null) return;
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const result = await apiClient.submitTechStepCorrection(recipeId, stepId, {
|
|
||||||
start: range.start,
|
|
||||||
end: range.end,
|
|
||||||
previousTechStepId,
|
|
||||||
correctedTechStepId: selectedTechStepId,
|
|
||||||
...(metadataTouched ? { ingredients: pendingIngredients, utensils: pendingUtensils } : {}),
|
|
||||||
});
|
|
||||||
onSubmitted(result);
|
|
||||||
onClose();
|
|
||||||
} catch (err) {
|
|
||||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
|
||||||
setError(errorMessageService.getLabel(code));
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestSpan(kind: "ingredient" | "utensil") {
|
|
||||||
setAwaitingSpanFor(kind);
|
|
||||||
onRequestSpan(kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelSpanSelection() {
|
|
||||||
setAwaitingSpanFor(null);
|
|
||||||
setActiveSpan(null);
|
|
||||||
setPickedIngredientId(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmIngredientSpan() {
|
|
||||||
if (activeSpan === null || pickedIngredientId === null) return;
|
|
||||||
const trimmed = spanQuantity.trim();
|
|
||||||
const parsedQuantity = trimmed.length > 0 ? Number(trimmed) : null;
|
|
||||||
setPendingIngredients((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
ingredientId: pickedIngredientId,
|
|
||||||
quantity:
|
|
||||||
parsedQuantity !== null && Number.isFinite(parsedQuantity) ? parsedQuantity : null,
|
|
||||||
unitId: spanUnitId,
|
|
||||||
start: activeSpan.range.start,
|
|
||||||
end: activeSpan.range.end,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
setMetadataTouched(true);
|
|
||||||
setActiveSpan(null);
|
|
||||||
setPickedIngredientId(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmUtensilSpan(utensilId: number) {
|
|
||||||
if (activeSpan === null) return;
|
|
||||||
setPendingUtensils((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ utensilId, start: activeSpan.range.start, end: activeSpan.range.end },
|
|
||||||
]);
|
|
||||||
setMetadataTouched(true);
|
|
||||||
setActiveSpan(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeIngredient(index: number) {
|
|
||||||
setPendingIngredients((prev) => prev.filter((_, i) => i !== index));
|
|
||||||
setMetadataTouched(true);
|
|
||||||
}
|
|
||||||
function removeUtensil(index: number) {
|
|
||||||
setPendingUtensils((prev) => prev.filter((_, i) => i !== index));
|
|
||||||
setMetadataTouched(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i]));
|
|
||||||
const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u]));
|
|
||||||
const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u]));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="tech-step-correction-popover" ref={popoverRef}>
|
|
||||||
<p className="tech-step-correction-popover__selection">
|
|
||||||
{t("recipes.techStepCorrection.selectionLabel", { text: selectedText })}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{awaitingSpanFor !== null ? (
|
|
||||||
<p className="tech-step-correction-popover__hint">
|
|
||||||
{t("recipes.techStepCorrection.selectSpanHint")}
|
|
||||||
</p>
|
|
||||||
) : activeSpan !== null ? (
|
|
||||||
<div className="tech-step-correction-popover__span-picker">
|
|
||||||
<p className="tech-step-correction-popover__selection">
|
|
||||||
{t("recipes.techStepCorrection.selectionLabel", { text: activeSpan.text })}
|
|
||||||
</p>
|
|
||||||
{activeSpan.kind === "ingredient" ? (
|
|
||||||
pickedIngredientId === null ? (
|
|
||||||
<CatalogSearchPicker
|
|
||||||
items={(catalogs?.ingredients ?? []).map((ingredient) => ({
|
|
||||||
id: ingredient.id,
|
|
||||||
label: t(`catalog.ingredients.${ingredient.key}`),
|
|
||||||
}))}
|
|
||||||
onSelect={setPickedIngredientId}
|
|
||||||
placeholder={t("recipes.form.searchIngredientPlaceholder")}
|
|
||||||
emptyLabel={t("recipes.form.noIngredientFound")}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="tech-step-correction-popover__quantity-line">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="any"
|
|
||||||
value={spanQuantity}
|
|
||||||
onChange={(e) => setSpanQuantity(e.target.value)}
|
|
||||||
aria-label={t("recipes.form.quantityLabel")}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={spanUnitId ?? ""}
|
|
||||||
onChange={(e) => setSpanUnitId(e.target.value ? Number(e.target.value) : null)}
|
|
||||||
aria-label={t("recipes.form.unitLabel")}
|
|
||||||
>
|
|
||||||
<option value="">{t("recipes.form.unitPlaceholder")}</option>
|
|
||||||
{(catalogs?.units ?? []).map((unit) => (
|
|
||||||
<option key={unit.id} value={unit.id}>
|
|
||||||
{t(`catalog.units.${unit.key}`)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button type="button" onClick={confirmIngredientSpan}>
|
|
||||||
{t("recipes.techStepCorrection.addToList")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<CatalogSearchPicker
|
|
||||||
items={(catalogs?.utensils ?? []).map((utensil) => ({
|
|
||||||
id: utensil.id,
|
|
||||||
label: t(`catalog.utensils.${utensil.key}`),
|
|
||||||
}))}
|
|
||||||
onSelect={confirmUtensilSpan}
|
|
||||||
placeholder={t("recipes.techStepCorrection.searchUtensilPlaceholder")}
|
|
||||||
emptyLabel={t("recipes.techStepCorrection.noUtensilFound")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="tech-step-correction-popover__cancel"
|
|
||||||
onClick={cancelSpanSelection}
|
|
||||||
>
|
|
||||||
{t("recipes.techStepCorrection.cancelSpanSelection")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : techSteps === null ? (
|
|
||||||
<p>{t("recipes.loading")}</p>
|
|
||||||
) : (
|
|
||||||
<div className="tech-step-correction-popover__confirm">
|
|
||||||
<section className="tech-step-correction-popover__technique-section">
|
|
||||||
<div className="tech-step-correction-popover__technique-header">
|
|
||||||
<h4>{t("recipes.techStepCorrection.techniqueSection")}</h4>
|
|
||||||
{previousTechStepId !== null && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={removeMatch}
|
|
||||||
className="tech-step-correction-popover__remove"
|
|
||||||
>
|
|
||||||
{t("recipes.techStepCorrection.removeMatch")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="tech-step-correction-popover__chosen-technique">
|
|
||||||
{selectedTechStepId !== null
|
|
||||||
? t("recipes.techStepCorrection.currentTechnique", {
|
|
||||||
technique: t(
|
|
||||||
`catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
: t("recipes.techStepCorrection.noTechniqueSelected")}
|
|
||||||
</p>
|
|
||||||
<CatalogSearchPicker
|
|
||||||
items={techSteps.map((techStep) => ({
|
|
||||||
id: techStep.id,
|
|
||||||
label: t(`catalog.techSteps.${techStep.key}`),
|
|
||||||
}))}
|
|
||||||
selectedId={selectedTechStepId ?? undefined}
|
|
||||||
onSelect={setSelectedTechStepId}
|
|
||||||
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
|
|
||||||
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="tech-step-correction-popover__metadata-section">
|
|
||||||
<h4>{t("recipes.techStepCorrection.ingredientsSection")}</h4>
|
|
||||||
<ul className="tech-step-correction-popover__chips">
|
|
||||||
{pendingIngredients.map((ingredient, index) => {
|
|
||||||
const view = ingredientById.get(ingredient.ingredientId);
|
|
||||||
const unit =
|
|
||||||
ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
|
|
||||||
const label = view ? t(`catalog.ingredients.${view.key}`) : "…";
|
|
||||||
return (
|
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
|
|
||||||
<li key={index} className="tech-step-correction-popover__chip">
|
|
||||||
{ingredient.quantity !== null ? `${ingredient.quantity} ` : ""}
|
|
||||||
{unit ? `${t(`catalog.units.${unit.key}`)} ` : ""}
|
|
||||||
{label}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeIngredient(index)}
|
|
||||||
title={t("recipes.techStepCorrection.removeIngredient")}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
<button type="button" onClick={() => requestSpan("ingredient")} disabled={isSubmitting}>
|
|
||||||
{t("recipes.techStepCorrection.addIngredient")}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="tech-step-correction-popover__metadata-section">
|
|
||||||
<h4>{t("recipes.techStepCorrection.utensilsSection")}</h4>
|
|
||||||
<ul className="tech-step-correction-popover__chips">
|
|
||||||
{pendingUtensils.map((utensil, index) => {
|
|
||||||
const view = utensilById.get(utensil.utensilId);
|
|
||||||
return (
|
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient chip list above.
|
|
||||||
<li key={index} className="tech-step-correction-popover__chip">
|
|
||||||
{view ? t(`catalog.utensils.${view.key}`) : "…"}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeUtensil(index)}
|
|
||||||
title={t("recipes.techStepCorrection.removeUtensil")}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
<button type="button" onClick={() => requestSpan("utensil")} disabled={isSubmitting}>
|
|
||||||
{t("recipes.techStepCorrection.addUtensil")}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="tech-step-correction-popover__confirm-button"
|
|
||||||
onClick={confirm}
|
|
||||||
disabled={isSubmitting || selectedTechStepId === null}
|
|
||||||
>
|
|
||||||
{t("recipes.techStepCorrection.confirm")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <p className="field-error">{error}</p>}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="tech-step-correction-popover__cancel"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{t("recipes.techStepCorrection.cancel")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -18,8 +18,6 @@ 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -28,54 +26,34 @@ 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, or a manual
|
* (older data, saved before that column pair existed — see
|
||||||
* correction — see `StepTechStep`'s schema doc comment) degrades to a
|
* `StepTechStep`'s schema doc comment) degrades to a keyword-only segment,
|
||||||
* keyword-only segment, same as before context spans existed at all.
|
* 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 each entry's own tight
|
* doc comment) but this re-sorts defensively (by context start when
|
||||||
* `start` rather than assuming it, and silently drops any entry whose own
|
* present, since context always starts at or before its own keyword)
|
||||||
* bounds don't make sense against `description` or a previously-accepted
|
* rather than assuming it, and silently drops any entry whose bounds don't
|
||||||
* entry's own tight keyword span — a malformed/out-of-date span degrades to
|
* make sense against `description` or a previously-accepted entry's own
|
||||||
* "just don't highlight that one" rather than a garbled slice or a crash.
|
* bounds — a malformed/out-of-date span degrades to "just don't highlight
|
||||||
*
|
* that one" rather than a garbled slice or a crash.
|
||||||
* Two entries' tight keyword spans are never allowed to overlap (the later
|
|
||||||
* one is dropped, same as always), but two entries' wider *context* clauses
|
|
||||||
* are allowed to overlap each other and are silently clipped to make room —
|
|
||||||
* context is cosmetic only (`StepDescription.tsx` renders it identically to
|
|
||||||
* plain text) and must never cost a *different* entry its own real keyword
|
|
||||||
* highlight. This matters far more than it looks: a clause `splitIntoClauses`
|
|
||||||
* (`tech-step-matcher.ts`) found only one NER candidate in gets that
|
|
||||||
* candidate's context spanning the *entire* clause — often the entire
|
|
||||||
* description — so without clipping, a single auto-detected match anywhere
|
|
||||||
* in a step could silently swallow every manual correction added anywhere
|
|
||||||
* else in that same step's description, with no error, just an unstyled
|
|
||||||
* word in the rendered text. Found via live testing: a "simmer" match's
|
|
||||||
* whole-description context ate a manual "setAside" correction added to a
|
|
||||||
* later, otherwise-plain word in the same step.
|
|
||||||
*/
|
*/
|
||||||
export function splitDescriptionByTechSteps(
|
export function splitDescriptionByTechSteps(
|
||||||
description: string,
|
description: string,
|
||||||
techSteps: StepTechStepView[],
|
techSteps: StepTechStepView[],
|
||||||
): DescriptionSegment[] {
|
): DescriptionSegment[] {
|
||||||
const sorted = [...techSteps].sort((a, b) => a.start - b.start);
|
const sorted = [...techSteps].sort(
|
||||||
|
(a, b) => (a.contextStart ?? a.start) - (b.contextStart ?? b.start),
|
||||||
|
);
|
||||||
|
|
||||||
// First pass: decide which entries survive at all, using only each
|
const segments: DescriptionSegment[] = [];
|
||||||
// entry's own tight keyword span for the cross-entry overlap check
|
let cursor = 0;
|
||||||
// (`start < keywordCursor`) — the one thing two independent matches are
|
for (const { techStep, start, end, contextStart, contextEnd } of sorted) {
|
||||||
// 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 (
|
||||||
start < keywordCursor ||
|
wideStart < cursor ||
|
||||||
wideStart > start ||
|
wideStart > start ||
|
||||||
start >= end ||
|
start >= end ||
|
||||||
end > wideEnd ||
|
end > wideEnd ||
|
||||||
|
|
@ -83,31 +61,12 @@ 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) {
|
||||||
|
|
@ -115,27 +74,16 @@ 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, source });
|
segments.push({ text: description.slice(start, end), techStep, isKeyword: true });
|
||||||
if (wideEnd > end) {
|
if (wideEnd > end) {
|
||||||
segments.push({
|
segments.push({ text: description.slice(end, wideEnd), techStep, isKeyword: false });
|
||||||
text: description.slice(end, wideEnd),
|
|
||||||
techStep,
|
|
||||||
isKeyword: false,
|
|
||||||
source,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
cursor = wideEnd;
|
cursor = wideEnd;
|
||||||
}
|
}
|
||||||
if (cursor < description.length) {
|
if (cursor < description.length) {
|
||||||
segments.push({
|
segments.push({ text: description.slice(cursor), techStep: null, isKeyword: false });
|
||||||
text: description.slice(cursor),
|
|
||||||
techStep: null,
|
|
||||||
isKeyword: false,
|
|
||||||
source: null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import { type RefObject, useCallback } from "react";
|
|
||||||
|
|
||||||
/** A `[start, end)` character range into a step's original `description` string — same convention as `StepTechStepView.start`/`end`. */
|
|
||||||
export interface TextSelectionRange {
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads the browser's current text selection, translated into a
|
|
||||||
* {@link TextSelectionRange} into a step's original `description` string —
|
|
||||||
* the shape `POST /recipes/:id/steps/:stepId/corrections` expects (see
|
|
||||||
* `SubmitTechStepCorrectionInput`, `packages/shared`).
|
|
||||||
*
|
|
||||||
* Works by walking up from each end of the selection's `Range` to the
|
|
||||||
* nearest ancestor carrying a `data-offset` attribute — set by
|
|
||||||
* `StepDescription`'s editable mode on every {@link DescriptionSegment}'s
|
|
||||||
* own wrapping element (`<span>`/`<button>`, `StepDescription.tsx`), each
|
|
||||||
* wrapping exactly its own text run and nothing else. `data-offset`'s value
|
|
||||||
* is that segment's own absolute start offset into `description`; added to
|
|
||||||
* the in-node offset the `Range` reports, this gives an exact absolute
|
|
||||||
* offset without needing to serialize/re-measure any text.
|
|
||||||
*
|
|
||||||
* Deliberately *not* using the more common `Range.toString().length`-from-
|
|
||||||
* the-container's-start technique for this problem — `StepDescription`
|
|
||||||
* always renders a `Tooltip` bubble alongside a keyword segment's own
|
|
||||||
* `<button>` (`Tooltip.tsx`, hidden via CSS, not removed from the DOM),
|
|
||||||
* whose text would silently pad that count past any keyword segment,
|
|
||||||
* corrupting every offset downstream of one.
|
|
||||||
*/
|
|
||||||
export function useTextSelection(containerRef: RefObject<HTMLElement | null>): {
|
|
||||||
getSelectionRange: () => TextSelectionRange | null;
|
|
||||||
} {
|
|
||||||
const getSelectionRange = useCallback((): TextSelectionRange | null => {
|
|
||||||
const selection = window.getSelection();
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0 || !container) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const range = selection.getRangeAt(0);
|
|
||||||
if (!container.contains(range.commonAncestorContainer)) return null;
|
|
||||||
|
|
||||||
const start = resolveOffset(container, range.startContainer, range.startOffset);
|
|
||||||
const end = resolveOffset(container, range.endContainer, range.endOffset);
|
|
||||||
if (start === null || end === null || start === end) return null;
|
|
||||||
|
|
||||||
return start < end ? { start, end } : { start: end, end: start };
|
|
||||||
}, [containerRef]);
|
|
||||||
|
|
||||||
return { getSelectionRange };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves `(node, nodeOffset)` (one end of a DOM `Range`, in the DOM's own
|
|
||||||
* mixed node/character-index convention) to an absolute character offset
|
|
||||||
* into `description`, or `null` if `node` isn't inside a segment
|
|
||||||
* `StepDescription` wrapped with `data-offset` at all — a selection edge
|
|
||||||
* that lands on whitespace/structure outside any segment shouldn't occur
|
|
||||||
* given every segment is wrapped, but this degrades to "no valid
|
|
||||||
* selection" rather than a wrong span or a crash if it somehow does.
|
|
||||||
*/
|
|
||||||
function resolveOffset(container: HTMLElement, node: Node, nodeOffset: number): number | null {
|
|
||||||
// A `Range` boundary that lands exactly on a segment's own wrapping
|
|
||||||
// element (rather than diving into its single Text child) reports
|
|
||||||
// `nodeOffset` as a *child index* (`0` or `1`, since every segment wraps
|
|
||||||
// exactly one Text node) — not a character offset. Resolved to the
|
|
||||||
// matching character offset (segment start vs. segment end) up front, so
|
|
||||||
// the walk below only ever deals in character offsets from here on.
|
|
||||||
let charOffset = nodeOffset;
|
|
||||||
if (node instanceof HTMLElement) {
|
|
||||||
const textChild = node.firstChild;
|
|
||||||
charOffset = nodeOffset > 0 ? (textChild?.textContent?.length ?? 0) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let current: Node | null = node;
|
|
||||||
while (current && current !== container) {
|
|
||||||
if (current instanceof HTMLElement && current.dataset.offset !== undefined) {
|
|
||||||
return Number(current.dataset.offset) + charOffset;
|
|
||||||
}
|
|
||||||
current = current.parentNode;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
@ -2,26 +2,7 @@
|
||||||
"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",
|
||||||
|
|
@ -42,10 +23,6 @@
|
||||||
"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": {
|
||||||
|
|
@ -122,6 +99,25 @@
|
||||||
"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",
|
||||||
|
|
@ -164,30 +160,6 @@
|
||||||
"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",
|
||||||
|
|
@ -202,11 +174,10 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -307,8 +278,7 @@
|
||||||
},
|
},
|
||||||
"shoppingList": {
|
"shoppingList": {
|
||||||
"title": "Liste de courses",
|
"title": "Liste de courses",
|
||||||
"loading": "Chargement de la liste de courses…",
|
"comingSoon": "Cette section arrive bientôt."
|
||||||
"empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning."
|
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"title": "Compte",
|
"title": "Compte",
|
||||||
|
|
@ -443,87 +413,7 @@
|
||||||
"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,4 +1,11 @@
|
||||||
import { DateTime, formatDateOnly, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
|
import {
|
||||||
|
addWeeks,
|
||||||
|
buildCalendarMonth,
|
||||||
|
DateTime,
|
||||||
|
formatDateOnly,
|
||||||
|
getWeekStart,
|
||||||
|
toDateOnly,
|
||||||
|
} from "@batch-cooking/date-tools";
|
||||||
import {
|
import {
|
||||||
MEALS,
|
MEALS,
|
||||||
type Meal,
|
type Meal,
|
||||||
|
|
@ -6,11 +13,10 @@ import {
|
||||||
type PlanningView,
|
type PlanningView,
|
||||||
WEEK_DAYS,
|
WEEK_DAYS,
|
||||||
} from "@batch-cooking/shared";
|
} 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 { 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. */
|
||||||
|
|
@ -43,7 +49,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` (features/planning/WeekNavigator.tsx).
|
// `CalendarPopover` below.
|
||||||
const [openSlot, setOpenSlot] = useState<PlanningSlot | null>(null);
|
const [openSlot, setOpenSlot] = useState<PlanningSlot | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -138,6 +144,160 @@ export function PlanningPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "17 au 23 août 2026" — collapses the month/year to just the end date when both ends of the week share it, spells it out on both ends otherwise (e.g. a week straddling two months). */
|
||||||
|
function formatWeekRange(weekStart: DateTime): string {
|
||||||
|
const weekEnd = weekStart.plus({ days: 6 });
|
||||||
|
const sameMonth = weekStart.hasSame(weekEnd, "month");
|
||||||
|
const startLabel = weekStart.toLocaleString(
|
||||||
|
sameMonth ? { day: "numeric" } : { day: "numeric", month: "long" },
|
||||||
|
{ locale: "fr" },
|
||||||
|
);
|
||||||
|
const endLabel = weekEnd.toLocaleString(
|
||||||
|
{ day: "numeric", month: "long", year: "numeric" },
|
||||||
|
{ locale: "fr" },
|
||||||
|
);
|
||||||
|
return `${startLabel} au ${endLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arrows + clickable label opening {@link CalendarPopover} — the week-selection UI at the top of the page. */
|
||||||
|
function WeekNavigator({
|
||||||
|
weekStart,
|
||||||
|
onChangeWeek,
|
||||||
|
}: {
|
||||||
|
weekStart: DateTime;
|
||||||
|
onChangeWeek: (weekStart: DateTime) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||||||
|
const isThisWeek = weekStart.hasSame(getWeekStart(DateTime.utc()), "day");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="week-nav">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="week-nav__arrow"
|
||||||
|
title={t("planning.weekNav.prevWeek")}
|
||||||
|
onClick={() => onChangeWeek(addWeeks(weekStart, -1))}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="week-nav__label"
|
||||||
|
onClick={() => setIsCalendarOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
📅 {t("planning.weekNav.label", { range: formatWeekRange(weekStart) })}
|
||||||
|
{isThisWeek && <span className="today-badge">{t("planning.weekNav.thisWeek")}</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="week-nav__arrow"
|
||||||
|
title={t("planning.weekNav.nextWeek")}
|
||||||
|
onClick={() => onChangeWeek(addWeeks(weekStart, 1))}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isCalendarOpen && (
|
||||||
|
<CalendarPopover
|
||||||
|
selectedWeekStart={weekStart}
|
||||||
|
onSelectDay={(day) => {
|
||||||
|
onChangeWeek(getWeekStart(day));
|
||||||
|
setIsCalendarOpen(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setIsCalendarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Month calendar letting the visitor jump to any week at once — selecting a day selects its whole (Monday-first) week. Closes itself on an outside click. */
|
||||||
|
function CalendarPopover({
|
||||||
|
selectedWeekStart,
|
||||||
|
onSelectDay,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
selectedWeekStart: DateTime;
|
||||||
|
onSelectDay: (day: DateTime) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Its own state: browsing to a different month to pick a week there
|
||||||
|
// shouldn't jump back every render — only re-anchors when the popover is
|
||||||
|
// first opened (`selectedWeekStart` at that point), not while it's open.
|
||||||
|
const [visibleMonth, setVisibleMonth] = useState(() => selectedWeekStart.startOf("month"));
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const today = toDateOnly(DateTime.utc());
|
||||||
|
const selectedWeekEnd = selectedWeekStart.plus({ days: 6 });
|
||||||
|
const weeks = buildCalendarMonth(visibleMonth);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="calendar-popover" ref={popoverRef}>
|
||||||
|
<div className="calendar-popover__header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t("planning.calendar.prevMonth")}
|
||||||
|
onClick={() => setVisibleMonth((month) => month.minus({ months: 1 }))}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
{visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t("planning.calendar.nextMonth")}
|
||||||
|
onClick={() => setVisibleMonth((month) => month.plus({ months: 1 }))}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="calendar-grid">
|
||||||
|
{WEEK_DAYS.map((weekDay) => (
|
||||||
|
<span key={weekDay} className="calendar-grid__weekday">
|
||||||
|
{t(`planning.days.${weekDay}`).charAt(0)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{weeks.flat().map((day) => {
|
||||||
|
const classNames = ["calendar-grid__day"];
|
||||||
|
if (!day.hasSame(visibleMonth, "month")) classNames.push("calendar-grid__day--muted");
|
||||||
|
if (day >= selectedWeekStart && day <= selectedWeekEnd) {
|
||||||
|
classNames.push("calendar-grid__day--in-selected-week");
|
||||||
|
}
|
||||||
|
if (day.hasSame(today, "day")) classNames.push("calendar-grid__day--today");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day.toISO()}
|
||||||
|
type="button"
|
||||||
|
className={classNames.join(" ")}
|
||||||
|
onClick={() => onSelectDay(day)}
|
||||||
|
>
|
||||||
|
{day.day}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The week grid itself — 7 day columns × 5 meal rows. */
|
/** The week grid itself — 7 day columns × 5 meal rows. */
|
||||||
function PlanningGrid({
|
function PlanningGrid({
|
||||||
weekStart,
|
weekStart,
|
||||||
|
|
@ -163,7 +323,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(`common.days.${weekDay}`)}</span>
|
<span className="day-name">{t(`planning.days.${weekDay}`)}</span>
|
||||||
<span className="day-date">{date.day}</span>
|
<span className="day-date">{date.day}</span>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,143 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Week navigator (arrows + clickable label opening the calendar) -------
|
||||||
|
.week-nav {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
|
||||||
|
&__arrow {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: 0.45rem var(--space-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-badge {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Calendar popover -------------------------------------------------------
|
||||||
|
.calendar-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--space-xs));
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
width: 18rem;
|
||||||
|
padding: var(--space-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
text-transform: capitalize;
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
&__weekday {
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
padding-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__day {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--muted {
|
||||||
|
color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--in-selected-week {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--today {
|
||||||
|
box-shadow: inset 0 0 0 2px var(--color-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- The grid itself --------------------------------------------------------
|
// --- 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,114 +1,10 @@
|
||||||
import { DateTime, formatDateOnly, getWeekStart } from "@batch-cooking/date-tools";
|
|
||||||
import type { ShoppingListItemView, ShoppingListView } from "@batch-cooking/shared";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { apiClient } from "../../api/client";
|
import { ComingSoonPage } from "../../components/ui/ComingSoonPage";
|
||||||
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";
|
|
||||||
|
|
||||||
/** Load state for the `GET /shopping-list` call — same discriminated-union shape as `PlanningPage`'s own `PlanningState`. */
|
/** Shopping list section — routed at `/liste-de-courses`. No backend yet, stub for now. */
|
||||||
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 (
|
||||||
<div className="shopping-list-page">
|
<ComingSoonPage title={t("shoppingList.title")} description={t("shoppingList.comingSoon")} />
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
// =============================================================================
|
|
||||||
// Styles specific to ShoppingListPage — colocated next to
|
|
||||||
// ShoppingListPage.tsx since nothing else uses these classes. Same page
|
|
||||||
// shell/status conventions as planning-page.scss (`.planning-page__header`/
|
|
||||||
// `__status`), a simple grouped list rather than a grid below it.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
.shopping-list-page {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
&__header {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-md);
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__status {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__status--error {
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- The list itself, grouped by aisle --------------------------------------
|
|
||||||
.shopping-list {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shopping-list__group-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
margin: 0 0 var(--space-sm);
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 1.2rem;
|
|
||||||
height: 1.2rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.shopping-list__items {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shopping-list__item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-name {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
&-quantity {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import {
|
|
||||||
INGREDIENT_CATEGORIES,
|
|
||||||
type IngredientCategory,
|
|
||||||
type ShoppingListItemView,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
|
|
||||||
/** One aisle's worth of shopping list lines — see {@link groupShoppingListItems}. */
|
|
||||||
export interface ShoppingListGroup {
|
|
||||||
category: IngredientCategory;
|
|
||||||
items: ShoppingListItemView[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Groups `items` by their ingredient's supermarket-aisle category (the same
|
|
||||||
* `IngredientCategory` the recipe form's `IngredientPicker` already browses
|
|
||||||
* by, see `ingredient-icons.tsx`'s `CategoryIcon`), in the app's canonical
|
|
||||||
* `INGREDIENT_CATEGORIES` order — a shopping list read aisle-by-aisle is far
|
|
||||||
* more useful in-store than one flat list. Within a group, lines are sorted
|
|
||||||
* by `ingredientLabel` — the caller's *already-translated* display name for
|
|
||||||
* that line, not the untranslated English `key` — so alphabetical order
|
|
||||||
* reads correctly in French; kept as a parameter (rather than calling
|
|
||||||
* `useTranslation` in here) so this stays a pure function the component can
|
|
||||||
* unit test without mounting i18next, same "logic extracted from the .tsx"
|
|
||||||
* split as every other feature in this codebase.
|
|
||||||
*/
|
|
||||||
export function groupShoppingListItems(
|
|
||||||
items: ShoppingListItemView[],
|
|
||||||
ingredientLabel: (item: ShoppingListItemView) => string,
|
|
||||||
): ShoppingListGroup[] {
|
|
||||||
const byCategory = new Map<IngredientCategory, ShoppingListItemView[]>();
|
|
||||||
for (const item of items) {
|
|
||||||
const category = item.ingredient.category;
|
|
||||||
const group = byCategory.get(category);
|
|
||||||
if (group) {
|
|
||||||
group.push(item);
|
|
||||||
} else {
|
|
||||||
byCategory.set(category, [item]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const groups: ShoppingListGroup[] = [];
|
|
||||||
for (const category of INGREDIENT_CATEGORIES) {
|
|
||||||
const groupItems = byCategory.get(category);
|
|
||||||
if (!groupItems) continue;
|
|
||||||
groups.push({
|
|
||||||
category,
|
|
||||||
items: [...groupItems].sort((a, b) =>
|
|
||||||
ingredientLabel(a).localeCompare(ingredientLabel(b), "fr"),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats an aggregated quantity for display — French grouping/decimal
|
|
||||||
* conventions, at most 2 decimals (e.g. `"1,5"`, `"250"`) so summing several
|
|
||||||
* recipes' quantities (`shopping-list.service.ts`'s `aggregateShoppingList`,
|
|
||||||
* floating-point addition) never surfaces a long trailing-digit artifact
|
|
||||||
* like `"149.99999999999997"`.
|
|
||||||
*/
|
|
||||||
export function formatShoppingListQuantity(quantity: number): string {
|
|
||||||
return quantity.toLocaleString("fr-FR", { maximumFractionDigits: 2 });
|
|
||||||
}
|
|
||||||
|
|
@ -27,6 +27,9 @@ 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
|
||||||
|
|
@ -42,92 +45,8 @@ 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:
|
|
||||||
|
|
|
||||||
3
experiments/llm-tech-step-poc/.gitignore
vendored
Normal file
3
experiments/llm-tech-step-poc/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
models/
|
||||||
210
experiments/llm-tech-step-poc/README.md
Normal file
210
experiments/llm-tech-step-poc/README.md
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
# PoC — détection d'actions culinaires : NLP, LLM local, hybride
|
||||||
|
|
||||||
|
Expérimentation autonome, **hors du monorepo pnpm** (`pnpm-workspace.yaml` ne
|
||||||
|
référence que `apps/*`/`packages/*`) : ce dossier a son propre
|
||||||
|
`package.json`/`tsconfig.json` et ne pollue ni les dépendances ni le build
|
||||||
|
Docker de `apps/api`.
|
||||||
|
|
||||||
|
Objectif : comparer, sur la même tâche (structurer une étape de recette en
|
||||||
|
séquence ordonnée d'actions culinaires) et le même jeu de 11 phrases, quatre
|
||||||
|
moteurs qui tournent tous 100 % en local :
|
||||||
|
|
||||||
|
| Script | Moteur | Ce qu'il apporte |
|
||||||
|
|---|---|---|
|
||||||
|
| `pnpm bench` | Mini LLM instruct via [`node-llama-cpp`](https://node-llama-cpp.withcat.ai/) (binding natif **dans ce process**), sortie JSON contrainte par schéma (GBNF grammar) | Généralise sans vocabulaire fixé à l'avance — au prix d'une latence de plusieurs secondes. |
|
||||||
|
| `pnpm bench:ollama` | Le même LLM (même `SYSTEM_PROMPT`, même tâche), mais via [Ollama](https://ollama.com/) — un serveur HTTP **local séparé** plutôt qu'un binding embarqué | Compare l'impact de l'architecture (client-serveur vs in-process) à sémantique identique, pas juste un autre modèle. |
|
||||||
|
| `pnpm bench:nlp` | Classifieur `node-nlp` frais (NER + découpage en clauses + classification d'intention), entraîné directement sur la taxonomie à 7 catégories de ce PoC | Rapide (centaines de ms), mais borné à son vocabulaire d'entraînement. |
|
||||||
|
| `pnpm bench:hybrid` | NLP d'abord, LLM (`node-llama-cpp`) en secours si le score NLP est trop faible | Le meilleur des deux : rapide sur le cas courant, généralise sur le cas difficile. |
|
||||||
|
|
||||||
|
Les quatre partagent le même code (`src/shared/`) : la taxonomie
|
||||||
|
`KitchenActionType`/`KitchenAction`/`RecipeStepAnalysis`
|
||||||
|
(`shared/kitchen-action.ts`), les 11 phrases de test
|
||||||
|
(`shared/test-sentences.ts`), et le harness de mesure/affichage
|
||||||
|
(`shared/benchmark-harness.ts`) — un seul jeu de phrases et un seul format
|
||||||
|
de sortie pour que les runs soient directement comparables, plutôt que
|
||||||
|
recopiés à la main dans chaque script (le défaut d'une toute première
|
||||||
|
version de ce PoC, où le pendant NLP vivait dans `apps/api` et copiait les
|
||||||
|
phrases manuellement). Les deux moteurs LLM (`node-llama-cpp`/Ollama)
|
||||||
|
partagent en plus le `SYSTEM_PROMPT` lui-même (exporté par
|
||||||
|
`llm-tech-step-poc.ts`, importé par `ollama-tech-step-poc.ts`) — même
|
||||||
|
sémantique testée sur les deux, seul le mécanisme de contrainte JSON change.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd experiments/llm-tech-step-poc
|
||||||
|
pnpm install --ignore-workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
`--ignore-workspace` est nécessaire : ce dossier n'étant pas dans les globs
|
||||||
|
de `pnpm-workspace.yaml` (`apps/*`/`packages/*`), un `pnpm install` normal
|
||||||
|
remonte jusqu'à la racine du monorepo et n'installe **rien** ici (aucune
|
||||||
|
erreur, juste un `node_modules` vide/inutilisable) — piège trouvé en écrivant
|
||||||
|
ce PoC.
|
||||||
|
|
||||||
|
`node-llama-cpp` télécharge/compile son binding natif llama.cpp à
|
||||||
|
l'installation (binaire prébuilt pour les plateformes courantes, sinon
|
||||||
|
compilation locale — nécessite alors un toolchain C++, voir sa doc
|
||||||
|
["Troubleshooting"](https://node-llama-cpp.withcat.ai/guide/troubleshooting)
|
||||||
|
en cas d'échec). `ollama` (le paquet npm) n'a lui aucune dépendance
|
||||||
|
native — c'est un simple client HTTP, rien à compiler.
|
||||||
|
|
||||||
|
## 1. Benchmark LLM seul — `pnpm bench`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm bench
|
||||||
|
```
|
||||||
|
|
||||||
|
Télécharge le modèle GGUF choisi via `LLM_TECH_STEP_MODEL` (une seule fois,
|
||||||
|
mis en cache dans `experiments/llm-tech-step-poc/models/`, jamais commité),
|
||||||
|
le charge, fait un appel de warm-up (chronométré à part — le tout premier
|
||||||
|
appel d'inférence sur un contexte fraîchement créé paie un coût caché de
|
||||||
|
plusieurs secondes que le chargement du modèle ne couvre pas), puis lance 3
|
||||||
|
répétitions sur chacune des 7 phrases de test.
|
||||||
|
|
||||||
|
| Valeur (défaut en gras) | Modèle | Pourquoi |
|
||||||
|
|---|---|---|
|
||||||
|
| **`qwen2.5-1.5b`** | Qwen2.5-1.5B-Instruct, `Q4_K_M` | Meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON — et, empiriquement (voir `Résultats obtenus` ci-dessous), aussi la latence la plus basse des deux sur ce benchmark, malgré ses ~50 % de paramètres en plus. |
|
||||||
|
| `llama-3.2-1b` | Llama-3.2-1B-Instruct, `Q4_K_M` | ~35 % de paramètres en moins, FR officiellement supporté, mais structuration JSON moins fiable à 1B — et pas plus rapide non plus dans les runs obtenus jusqu'ici. Conservé comme point de comparaison, pas comme choix "latence d'abord". |
|
||||||
|
|
||||||
|
> **Résultats obtenus** (Windows, backend Vulkan, une machine) : sur les 7
|
||||||
|
> phrases, Qwen2.5-1.5B a été systématiquement plus rapide que Llama-3.2-1B
|
||||||
|
> malgré sa taille plus grande — l'inverse de l'hypothèse a priori "moins de
|
||||||
|
> paramètres = plus rapide". Un seul run sur une seule machine/un seul
|
||||||
|
> backend ne généralise pas forcément (CUDA/CPU pur donneraient
|
||||||
|
> possiblement un classement différent).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LLM_TECH_STEP_MODEL=llama-3.2-1b pnpm bench
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hors-ligne / CI** : `LLM_TECH_STEP_MODEL_PATH=/chemin/vers/un.gguf pnpm bench`
|
||||||
|
pointe directement vers un fichier déjà téléchargé, sans passer par la
|
||||||
|
résolution/téléchargement Hugging Face.
|
||||||
|
|
||||||
|
## 2. Benchmark LLM via Ollama — `pnpm bench:ollama`
|
||||||
|
|
||||||
|
Prérequis : [Ollama](https://ollama.com/download) installé, **et son serveur
|
||||||
|
lancé** (`ollama serve` dans un terminal séparé, ou l'app de bureau Ollama
|
||||||
|
qui le lance automatiquement) — ce script ne démarre pas le serveur
|
||||||
|
lui-même, contrairement à `pnpm bench` qui charge son modèle directement.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ollama serve # si pas déjà lancé (ou l'app de bureau Ollama)
|
||||||
|
pnpm bench:ollama
|
||||||
|
```
|
||||||
|
|
||||||
|
Même tâche, même `SYSTEM_PROMPT`, mêmes modèles (`OLLAMA_TECH_STEP_MODEL`,
|
||||||
|
mêmes valeurs `qwen2.5-1.5b`/`llama-3.2-1b` que `LLM_TECH_STEP_MODEL`) que
|
||||||
|
la section 1 — mais une **implémentation architecturalement différente**,
|
||||||
|
testée pour ça plutôt que comme une simple redite :
|
||||||
|
|
||||||
|
| | `node-llama-cpp` (section 1) | Ollama (cette section) |
|
||||||
|
|---|---|---|
|
||||||
|
| Où tourne l'inférence | Dans CE process Node (binding natif) | Dans le process `ollama serve`, séparé |
|
||||||
|
| Installation | Binaire natif compilé/téléchargé au `pnpm install` | Client HTTP pur, rien à compiler |
|
||||||
|
| Gestion du modèle | `resolveModelFile` télécharge le GGUF dans `models/` de ce projet | `ollama pull` — Ollama gère son propre cache (`~/.ollama/models`) |
|
||||||
|
| Schéma JSON nullable | `oneOf: [{type:"null"}, {type:"..."}]` (contrainte de la grammaire GBNF) | `type: ["string", "null"]` (JSON Schema standard, plus simple) |
|
||||||
|
| Mesure RSS du benchmark | Fiable — le binding alloue dans ce process | **Sans intérêt** — l'inférence tourne ailleurs, voir ci-dessous |
|
||||||
|
|
||||||
|
**La colonne `RSS moy.` du récapitulatif ne veut RIEN dire pour ce script** —
|
||||||
|
`process.memoryUsage()` mesure ce process Node, pas le process `ollama
|
||||||
|
serve` où l'inférence a réellement lieu. Le script l'affiche quand même
|
||||||
|
(même harness que les trois autres) mais rappelle ce point juste avant le
|
||||||
|
tableau.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OLLAMA_TECH_STEP_MODEL=llama-3.2-1b pnpm bench:ollama
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hôte Ollama personnalisé** (serveur distant, port non standard) :
|
||||||
|
`OLLAMA_TECH_STEP_HOST=http://mon-serveur:11434 pnpm bench:ollama`
|
||||||
|
(défaut : `http://127.0.0.1:11434`).
|
||||||
|
|
||||||
|
## 3. Benchmark NLP seul — `pnpm bench:nlp`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm bench:nlp
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun téléchargement, aucune base de données — tourne en quelques secondes.
|
||||||
|
`NlpTechStepClassifier` (`src/nlp-tech-step-poc.ts`) est un classifieur
|
||||||
|
`node-nlp` **frais**, écrit pour ce PoC plutôt qu'une réutilisation de
|
||||||
|
`TechStepClassifierService` (`apps/api/src/lib/recipe-matching/
|
||||||
|
tech-step-matcher.ts`) — deux raisons :
|
||||||
|
|
||||||
|
1. **Comparaison vraiment terme à terme** : `TechStepClassifierService`
|
||||||
|
classe sur la taxonomie fine à ~26 techniques de
|
||||||
|
`tech-step-training-data.ts` (DB-backed), pas sur les 7 catégories de
|
||||||
|
`KitchenActionType` que le LLM produit — les nombres de détections
|
||||||
|
n'étaient pas directement comparables. Ce classifieur-ci est entraîné
|
||||||
|
directement sur les 7 mêmes catégories.
|
||||||
|
2. **Un score de confiance exploitable** : `TechStepClassifierService`
|
||||||
|
masque son score en retombant silencieusement sur l'ancre NER dès qu'il
|
||||||
|
est sous son seuil interne — utile en prod, mais ça cache le signal dont
|
||||||
|
le pipeline hybride (section 3) a besoin pour décider quand basculer
|
||||||
|
vers le LLM. Ce classifieur-ci renvoie toujours le score BRUT.
|
||||||
|
|
||||||
|
Même pipeline NER → découpage en clauses → classification par clause que
|
||||||
|
`tech-step-matcher.ts`, implémentation propre à ce PoC (simplifiée : pas de
|
||||||
|
priorité aux frontières de phrase dans le découpage). Le corpus
|
||||||
|
d'entraînement (`TRAINING_DATA` dans `nlp-tech-step-poc.ts`) préfère un
|
||||||
|
synonyme mono-mot ("revenir") à une phrase figée ("faites revenir") quand
|
||||||
|
c'est possible — une leçon tirée d'un run antérieur de ce PoC : "faites
|
||||||
|
revenir" (2 mots) ratait "faites-**les**-revenir" (le pronom clitique
|
||||||
|
français insère un mot entre les deux et casse un matching de phrase
|
||||||
|
contiguë), un synonyme mono-mot matche quel que soit ce qui le précède.
|
||||||
|
|
||||||
|
## 4. Pipeline hybride — `pnpm bench:hybrid`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm bench:hybrid
|
||||||
|
```
|
||||||
|
|
||||||
|
Combine les deux : le NLP analyse TOUJOURS en premier (chemin rapide) ; si
|
||||||
|
sa confiance globale (le minimum de confiance de ses clauses) est sous
|
||||||
|
`NLP_TRUST_THRESHOLD` (`0.6`, tunable dans `hybrid-tech-step-poc.ts`) ou
|
||||||
|
qu'il n'a rien trouvé du tout, son résultat est ENTIÈREMENT écarté et
|
||||||
|
l'étape est réanalysée par le LLM. Le récapitulatif affiche, par phrase,
|
||||||
|
quel moteur a répondu (`moteur`) et la confiance NLP qui a déclenché la
|
||||||
|
décision (`confiance NLP`) — de quoi ajuster le seuil en observant sur
|
||||||
|
quelles phrases le pipeline bascule.
|
||||||
|
|
||||||
|
Nécessite le modèle LLM (même téléchargement/options `LLM_TECH_STEP_MODEL`/
|
||||||
|
`LLM_TECH_STEP_MODEL_PATH` que la section 1) puisqu'il reste le moteur de
|
||||||
|
secours.
|
||||||
|
|
||||||
|
**Limite assumée** : quand le chemin NLP est pris, seuls `action`/`verb`
|
||||||
|
sont réellement connus — `ingredients`/`utensils` restent `[]` et
|
||||||
|
`durationMinutes`/`temperature` restent `null`, jamais inventés (le
|
||||||
|
classifieur NLP ne peut structurellement pas les extraire). Seul le chemin
|
||||||
|
LLM remplit tous les champs. Un vrai système hybride ferait probablement
|
||||||
|
remonter le champ `source` jusqu'à l'UI pour ne promettre que ce que chaque
|
||||||
|
chemin fournit réellement.
|
||||||
|
|
||||||
|
## Limites de ce PoC
|
||||||
|
|
||||||
|
- Pas de jeu d'évaluation étiqueté ni de métrique de précision automatisée
|
||||||
|
— les 11 phrases sont inspectées à l'œil, pas notées.
|
||||||
|
- La contrainte JSON (grammaire GBNF ou JSON Schema Ollama) ne garantit
|
||||||
|
qu'une syntaxe JSON conforme au schéma, jamais la justesse sémantique du
|
||||||
|
contenu.
|
||||||
|
- Le corpus du classifieur NLP frais est volontairement compact (PoC, pas un
|
||||||
|
remplacement du corpus production `tech-step-training-data.ts`) — des
|
||||||
|
formes non couvertes (conjugaisons, synonymes absents) manqueront, comme
|
||||||
|
pour n'importe quel corpus fini.
|
||||||
|
- `NLP_TRUST_THRESHOLD` (`0.6`) est un point de départ raisonnable, pas une
|
||||||
|
valeur empiriquement optimisée — à ajuster en observant la colonne
|
||||||
|
`moteur` du récapitulatif hybride sur des étapes réelles.
|
||||||
|
- Le delta de RSS process est une approximation de la RAM réellement utilisée
|
||||||
|
pour `node-llama-cpp`/`node-nlp` (un binding natif alloue dans le même
|
||||||
|
process, donc le RSS la capture, mais au bruit du GC/de l'allocateur près)
|
||||||
|
— pas une mesure isolée, et carrément **sans valeur** pour `bench:ollama`
|
||||||
|
(l'inférence tourne dans `ollama serve`, un process séparé, voir la
|
||||||
|
section 2).
|
||||||
|
- Latence `node-llama-cpp` mesurée en CPU pur (pas de configuration GPU dans
|
||||||
|
ce PoC) — un déploiement réel voudrait évaluer l'offload GPU (`gpuLayers`
|
||||||
|
dans les options `loadModel`) si la cible dispose d'un GPU. Ollama, lui,
|
||||||
|
détecte et utilise l'accélération matérielle disponible automatiquement —
|
||||||
|
une différence qui peut à elle seule expliquer un écart de latence entre
|
||||||
|
les deux moteurs LLM, indépendamment du modèle choisi.
|
||||||
30
experiments/llm-tech-step-poc/package.json
Normal file
30
experiments/llm-tech-step-poc/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "llm-tech-step-poc",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "PoC autonome : quatre moteurs de détection d'actions culinaires dans une étape de recette (LLM local via node-llama-cpp, le même LLM via Ollama, classifieur node-nlp frais, pipeline hybride NLP+LLM), benchmarkés sur le même jeu de phrases.",
|
||||||
|
"scripts": {
|
||||||
|
"bench": "tsx src/llm-tech-step-poc.ts",
|
||||||
|
"bench:ollama": "tsx src/ollama-tech-step-poc.ts",
|
||||||
|
"bench:nlp": "tsx src/nlp-tech-step-poc.ts",
|
||||||
|
"bench:hybrid": "tsx src/hybrid-tech-step-poc.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"node-llama-cpp": "^3.20.0",
|
||||||
|
"node-nlp": "4.27.0",
|
||||||
|
"ollama": "^0.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.9.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild",
|
||||||
|
"node-llama-cpp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
243
experiments/llm-tech-step-poc/src/hybrid-tech-step-poc.ts
Normal file
243
experiments/llm-tech-step-poc/src/hybrid-tech-step-poc.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
/**
|
||||||
|
* PoC autonome — pipeline HYBRIDE combinant le classifieur `node-nlp` frais
|
||||||
|
* de `nlp-tech-step-poc.ts` (rapide, ~26 fois moins gourmand en latence
|
||||||
|
* mesuré dans les runs précédents de ce PoC) et le LLM local de
|
||||||
|
* `llm-tech-step-poc.ts` (plus lent, mais qui généralise mieux sur les
|
||||||
|
* phrases où le NLP échoue franchement — voir les cas piège de
|
||||||
|
* `shared/test-sentences.ts`).
|
||||||
|
*
|
||||||
|
* Principe — "fast path, escalade sur signal faible" :
|
||||||
|
*
|
||||||
|
* 1. Le NLP analyse l'étape en premier, TOUJOURS (chemin rapide, quelques
|
||||||
|
* centaines de ms).
|
||||||
|
* 2. Sa {@link NlpStepAnalysis.overallConfidence} (le score BRUT, jamais
|
||||||
|
* masqué — voir la doc de `nlp-tech-step-poc.ts`) est comparée à
|
||||||
|
* {@link NLP_TRUST_THRESHOLD}.
|
||||||
|
* 3. Score suffisant ET au moins une action trouvée -> le résultat NLP est
|
||||||
|
* gardé tel quel (`source: "nlp"`).
|
||||||
|
* 4. Score insuffisant (ou aucune action trouvée du tout) -> le résultat
|
||||||
|
* NLP est ENTIÈREMENT écarté, l'étape est réanalysée par le LLM
|
||||||
|
* (`source: "llm"`), plus lent mais dont ce PoC a déjà montré qu'il
|
||||||
|
* généralise mieux sur les phrases où le NLP échoue (actions
|
||||||
|
* implicites, pronoms clitiques cassant un matching de phrase, etc.).
|
||||||
|
*
|
||||||
|
* Limite assumée du PoC : le résultat NLP ne porte QUE `action`/`verb`
|
||||||
|
* (voir `NlpTechStepClassifier`, structurellement incapable d'extraire
|
||||||
|
* ingrédients/durée/température/ustensiles) — quand le chemin NLP est pris,
|
||||||
|
* les autres champs de {@link KitchenAction} restent vides/`null`, jamais
|
||||||
|
* inventés. Le chemin LLM, lui, remplit tous les champs. C'est un compromis
|
||||||
|
* délibéré "rapide-et-grossier vs. lent-et-riche", pas un défaut à corriger
|
||||||
|
* — un vrai système hybride ferait probablement remonter le champ
|
||||||
|
* `source` jusqu'à l'UI pour ne promettre que ce que chaque chemin fournit
|
||||||
|
* réellement.
|
||||||
|
*
|
||||||
|
* Usage :
|
||||||
|
*
|
||||||
|
* ```bash
|
||||||
|
* cd experiments/llm-tech-step-poc
|
||||||
|
* pnpm install --ignore-workspace
|
||||||
|
* pnpm bench:hybrid
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import {
|
||||||
|
LocalLlmStepAnalyzer,
|
||||||
|
RECOMMENDED_MODELS,
|
||||||
|
type RecommendedModelKey,
|
||||||
|
} from "./llm-tech-step-poc.js";
|
||||||
|
import { type NlpStepAnalysis, NlpTechStepClassifier } from "./nlp-tech-step-poc.js";
|
||||||
|
import {
|
||||||
|
type BenchmarkSample,
|
||||||
|
printSummaryTable,
|
||||||
|
runBenchmark,
|
||||||
|
} from "./shared/benchmark-harness.js";
|
||||||
|
import type { KitchenAction } from "./shared/kitchen-action.js";
|
||||||
|
import { isMainModule } from "./shared/module-entry.js";
|
||||||
|
import type { BenchmarkSentence } from "./shared/test-sentences.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seuil de confiance NLP en dessous duquel une étape est réanalysée par le
|
||||||
|
* LLM plutôt que de garder le résultat NLP. Paramètre PROPRE à ce PoC —
|
||||||
|
* délibérément plus permissif que `CONFIDENCE_THRESHOLD` de
|
||||||
|
* `tech-step-matcher.ts` (`0.75`, empiriquement ajusté contre son propre
|
||||||
|
* corpus de production) : ce classifieur-ci a un corpus bien plus compact
|
||||||
|
* (voir `nlp-tech-step-poc.ts`), un seuil aussi strict escaladerait presque
|
||||||
|
* tout vers le LLM et ne testerait jamais vraiment le chemin rapide. `0.6`
|
||||||
|
* est un point de départ raisonnable pour un PoC, pas une valeur
|
||||||
|
* empiriquement optimisée — à ajuster en observant le récapitulatif
|
||||||
|
* (colonne `moteur`) sur des étapes réelles.
|
||||||
|
*/
|
||||||
|
const NLP_TRUST_THRESHOLD = 0.6;
|
||||||
|
|
||||||
|
/** Quel moteur a produit le résultat final pour une étape. */
|
||||||
|
export type HybridSource = "nlp" | "llm";
|
||||||
|
|
||||||
|
/** Résultat du pipeline hybride pour une étape — mêmes `actions` que les deux autres moteurs, plus la traçabilité de quel chemin a été pris et pourquoi. */
|
||||||
|
export interface HybridStepAnalysis {
|
||||||
|
originalText: string;
|
||||||
|
source: HybridSource;
|
||||||
|
/** Confiance globale renvoyée par le NLP — calculée et conservée MÊME quand le LLM finit par traiter l'étape, pour que le benchmark montre ce qui a déclenché l'escalade. */
|
||||||
|
nlpConfidence: number;
|
||||||
|
actions: KitchenAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convertit les matches du classifieur NLP en `KitchenAction[]` — seuls `action`/`verb` sont réellement connus, voir le doc-comment en tête de fichier. */
|
||||||
|
function toKitchenActions(nlpResult: NlpStepAnalysis): KitchenAction[] {
|
||||||
|
return nlpResult.matches.map((match) => ({
|
||||||
|
action: match.action,
|
||||||
|
verb: match.matchedText,
|
||||||
|
ingredients: [],
|
||||||
|
durationMinutes: null,
|
||||||
|
temperature: null,
|
||||||
|
utensils: [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combine {@link NlpTechStepClassifier} et {@link LocalLlmStepAnalyzer}
|
||||||
|
* derrière une seule méthode `analyzeStep` — vraie `class` (pas un objet
|
||||||
|
* littéral), même convention que les deux moteurs qu'elle orchestre : elle
|
||||||
|
* possède un état réel (les deux moteurs sous-jacents), pas juste des
|
||||||
|
* fonctions groupées sans état.
|
||||||
|
*/
|
||||||
|
export class HybridStepAnalyzer {
|
||||||
|
private readonly _nlp: NlpTechStepClassifier;
|
||||||
|
private readonly _llm: LocalLlmStepAnalyzer;
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
this._nlp = new NlpTechStepClassifier();
|
||||||
|
this._llm = new LocalLlmStepAnalyzer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initialise le LLM (chargement du modèle) — le NLP n'a pas de phase d'initialisation séparée, son entraînement est mémoïsé au premier appel (voir `NlpTechStepClassifier`). */
|
||||||
|
public async initialize(modelKey: RecommendedModelKey): Promise<void> {
|
||||||
|
await this._llm.initialize(modelKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Warm-up des deux moteurs — voir la doc de chacun (`NlpTechStepClassifier.warmUp`/`LocalLlmStepAnalyzer.warmUp`) pour pourquoi c'est nécessaire séparément du benchmark. */
|
||||||
|
public async warmUp(): Promise<void> {
|
||||||
|
await this._nlp.warmUp();
|
||||||
|
await this._llm.warmUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyse une étape : NLP d'abord (toujours), LLM seulement si le score
|
||||||
|
* NLP est sous {@link NLP_TRUST_THRESHOLD} ou qu'aucune action n'a été
|
||||||
|
* trouvée du tout — voir le doc-comment en tête de fichier pour le détail
|
||||||
|
* de la logique de décision.
|
||||||
|
*/
|
||||||
|
public async analyzeStep(sentence: BenchmarkSentence): Promise<HybridStepAnalysis> {
|
||||||
|
const nlpResult = await this._nlp.analyzeStep(sentence.text, sentence.locale);
|
||||||
|
const nlpIsTrustworthy =
|
||||||
|
nlpResult.overallConfidence >= NLP_TRUST_THRESHOLD && nlpResult.matches.length > 0;
|
||||||
|
|
||||||
|
if (nlpIsTrustworthy) {
|
||||||
|
return {
|
||||||
|
originalText: sentence.text,
|
||||||
|
source: "nlp",
|
||||||
|
nlpConfidence: nlpResult.overallConfidence,
|
||||||
|
actions: toKitchenActions(nlpResult),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const llmResult = await this._llm.analyzeStep(sentence.text);
|
||||||
|
return {
|
||||||
|
originalText: sentence.text,
|
||||||
|
source: "llm",
|
||||||
|
nlpConfidence: nlpResult.overallConfidence,
|
||||||
|
actions: llmResult.actions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Libère le LLM (le NLP n'a pas de ressource native à libérer). */
|
||||||
|
public async dispose(): Promise<void> {
|
||||||
|
await this._llm.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benchmark
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Imprime le détail de chaque échantillon — quel moteur a répondu, avec quelle confiance NLP, et les actions obtenues. */
|
||||||
|
function printDetailedResults(samples: readonly BenchmarkSample<HybridStepAnalysis>[]): void {
|
||||||
|
for (const sample of samples) {
|
||||||
|
console.info(
|
||||||
|
`\n[${sample.sentence.id}] (${sample.sentence.locale}) — ${sample.latencyMs.toFixed(0)} ms, moteur: ${sample.result.source} (confiance NLP ${sample.result.nlpConfidence.toFixed(2)})`,
|
||||||
|
);
|
||||||
|
console.info(` texte : ${sample.sentence.text}`);
|
||||||
|
console.info(` attendu : ${sample.sentence.note}`);
|
||||||
|
console.table(
|
||||||
|
sample.result.actions.map((action) => ({
|
||||||
|
action: action.action,
|
||||||
|
verbe: action.verb,
|
||||||
|
ingrédients: action.ingredients.join(", "),
|
||||||
|
"durée (min)": action.durationMinutes ?? "—",
|
||||||
|
température: action.temperature ?? "—",
|
||||||
|
ustensiles: action.utensils.join(", "),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const modelKey: RecommendedModelKey =
|
||||||
|
process.env.LLM_TECH_STEP_MODEL === "llama-3.2-1b" ? "llama-3.2-1b" : "qwen2.5-1.5b";
|
||||||
|
console.info(
|
||||||
|
`[hybrid] modèle LLM de secours : ${modelKey} (${RECOMMENDED_MODELS[modelKey].rationale})`,
|
||||||
|
);
|
||||||
|
console.info(`[hybrid] seuil de confiance NLP : ${NLP_TRUST_THRESHOLD}`);
|
||||||
|
|
||||||
|
const analyzer = new HybridStepAnalyzer();
|
||||||
|
try {
|
||||||
|
console.info("[hybrid] initialisation (chargement du modèle LLM de secours)...");
|
||||||
|
await analyzer.initialize(modelKey);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[hybrid] échec de l'initialisation", err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const warmUpStartedAt = performance.now();
|
||||||
|
try {
|
||||||
|
await analyzer.warmUp();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[hybrid] échec du warm-up — le benchmark continue quand même", err);
|
||||||
|
}
|
||||||
|
console.info(`[hybrid] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const benchmarkStartedAt = performance.now();
|
||||||
|
const samples = await runBenchmark<HybridStepAnalysis>({
|
||||||
|
logPrefix: "[hybrid]",
|
||||||
|
countOf: (result) => result.actions.length,
|
||||||
|
countLabel: "action(s) détectée(s)",
|
||||||
|
analyze: (sentence) => analyzer.analyzeStep(sentence),
|
||||||
|
});
|
||||||
|
console.info(
|
||||||
|
`[hybrid] benchmark complet en ${(performance.now() - benchmarkStartedAt).toFixed(0)} ms`,
|
||||||
|
);
|
||||||
|
printDetailedResults(samples);
|
||||||
|
printSummaryTable(samples, (result) => result.actions.length, "actions détectées", [
|
||||||
|
{
|
||||||
|
label: "moteur",
|
||||||
|
valueOf: (lastSample) => lastSample.result.source,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "confiance NLP",
|
||||||
|
valueOf: (lastSample) => lastSample.result.nlpConfidence.toFixed(2),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await analyzer.dispose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[hybrid] erreur lors de la libération des moteurs", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMainModule(import.meta.url)) {
|
||||||
|
await main();
|
||||||
|
}
|
||||||
445
experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts
Normal file
445
experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts
Normal file
|
|
@ -0,0 +1,445 @@
|
||||||
|
/**
|
||||||
|
* PoC autonome — détection d'actions culinaires via un mini LLM local
|
||||||
|
* (`node-llama-cpp`), à comparer au classifieur `node-nlp` frais de
|
||||||
|
* `nlp-tech-step-poc.ts` (et, au-delà, au pipeline `node-nlp` de production
|
||||||
|
* dans `apps/api/src/lib/recipe-matching/tech-step-matcher.ts`).
|
||||||
|
*
|
||||||
|
* Ce PoC teste une approche différente d'un classifieur par clause : demander
|
||||||
|
* à un petit LLM instruct local d'extraire en une seule passe la séquence
|
||||||
|
* ORDONNÉE de toutes les actions atomiques d'une étape, sous forme d'un JSON
|
||||||
|
* structuré — sans vocabulaire fixé à l'avance, au prix d'une latence et
|
||||||
|
* d'une empreinte mémoire bien plus élevées (un modèle de ~1 à 2 Md de
|
||||||
|
* paramètres contre un classifieur NLP léger). Tourne entièrement en local,
|
||||||
|
* sans appel réseau à l'inférence (le seul accès réseau de ce fichier est le
|
||||||
|
* téléchargement ponctuel du modèle GGUF, voir {@link resolveModelPath}).
|
||||||
|
*
|
||||||
|
* Portée volontairement limitée à un fichier autonome, hors du monorepo
|
||||||
|
* pnpm (`pnpm-workspace.yaml` ne référence que `apps/*`/`packages/*`) :
|
||||||
|
* c'est un script d'expérimentation jetable, pas un module destiné à être
|
||||||
|
* consommé par `apps/api` — même statut que les scripts one-off déjà
|
||||||
|
* exemptés de la convention "un `try`/`catch` par `await`" du repo
|
||||||
|
* (`prisma/seed.ts`, `apps/api/src/scripts/seed-runtime.ts`) : ici aussi,
|
||||||
|
* laisser une erreur se propager telle quelle jusqu'au point d'appel qui
|
||||||
|
* décide quoi en faire (le run complet du benchmark, ou `main()` en tout
|
||||||
|
* dernier ressort) est plus lisible qu'un `catch { throw err; }` répété
|
||||||
|
* sans rien y ajouter.
|
||||||
|
*
|
||||||
|
* Usage : voir `README.md` à côté de ce fichier (installation, modèle,
|
||||||
|
* variables d'environnement). En bref :
|
||||||
|
*
|
||||||
|
* ```bash
|
||||||
|
* cd experiments/llm-tech-step-poc
|
||||||
|
* pnpm install --ignore-workspace
|
||||||
|
* pnpm bench
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
getLlama,
|
||||||
|
LlamaChatSession,
|
||||||
|
type LlamaJsonSchemaGrammar,
|
||||||
|
resolveModelFile,
|
||||||
|
} from "node-llama-cpp";
|
||||||
|
import {
|
||||||
|
type BenchmarkSample,
|
||||||
|
printSummaryTable,
|
||||||
|
runBenchmark,
|
||||||
|
} from "./shared/benchmark-harness.js";
|
||||||
|
import { KitchenActionType, type RecipeStepAnalysis } from "./shared/kitchen-action.js";
|
||||||
|
import { isMainModule } from "./shared/module-entry.js";
|
||||||
|
import type { BenchmarkSentence } from "./shared/test-sentences.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schéma JSON — grammaire GBNF imposée à la génération
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schéma JSON d'une action, dans le sous-ensemble supporté par
|
||||||
|
* `LlamaChatSession`+`llama.createGrammarForJsonSchema` (object/array/
|
||||||
|
* string/number/enum/oneOf — pas d'union `type: [...]` pour les champs
|
||||||
|
* nullable, node-llama-cpp veut `oneOf: [{type:"null"}, {type:"..."}]`,
|
||||||
|
* voir la doc "Using Grammar"). Champ à champ, en miroir strict de
|
||||||
|
* `KitchenAction` (`shared/kitchen-action.ts`) : la grammaire ne fait
|
||||||
|
* qu'imposer une SYNTAXE JSON valide conforme à ce schéma, elle ne garantit
|
||||||
|
* pas que le modèle choisisse la bonne catégorie/le bon champ — c'est le
|
||||||
|
* rôle du prompt système ({@link SYSTEM_PROMPT}) de guider la sémantique.
|
||||||
|
*/
|
||||||
|
const KITCHEN_ACTION_JSON_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
action: { enum: Object.values(KitchenActionType) },
|
||||||
|
verb: { type: "string" },
|
||||||
|
ingredients: { type: "array", items: { type: "string" } },
|
||||||
|
durationMinutes: { oneOf: [{ type: "null" }, { type: "number" }] },
|
||||||
|
temperature: { oneOf: [{ type: "null" }, { type: "string" }] },
|
||||||
|
utensils: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
required: ["action", "verb", "ingredients", "durationMinutes", "temperature", "utensils"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Racine du schéma imposé au modèle — un objet `{ actions: [...] }` plutôt
|
||||||
|
* qu'un tableau nu en racine (node-llama-cpp exige un `type: "object"` en
|
||||||
|
* racine de la grammaire JSON). `originalText` n'y figure pas : le faire
|
||||||
|
* recopier le texte d'entrée gaspillerait des tokens de génération et
|
||||||
|
* risquerait une recopie légèrement différente de l'original (espaces,
|
||||||
|
* ponctuation) sans aucun bénéfice — ce champ est réattaché
|
||||||
|
* programmatiquement par {@link LocalLlmStepAnalyzer.analyzeStep} à partir
|
||||||
|
* de l'argument d'entrée, pas de la réponse du modèle.
|
||||||
|
*/
|
||||||
|
const KITCHEN_ACTIONS_JSON_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
actions: { type: "array", items: KITCHEN_ACTION_JSON_SCHEMA },
|
||||||
|
},
|
||||||
|
required: ["actions"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Forme brute que renvoie `grammar.parse()` pour {@link KITCHEN_ACTIONS_JSON_SCHEMA} — reconverti en {@link RecipeStepAnalysis} par {@link LocalLlmStepAnalyzer.analyzeStep}. */
|
||||||
|
interface KitchenActionsGrammarResult {
|
||||||
|
actions: RecipeStepAnalysis["actions"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instructions système — porte toute la sémantique que la grammaire GBNF ne
|
||||||
|
* peut pas imposer (elle ne contraint que la forme JSON, jamais le
|
||||||
|
* contenu) : la définition de chaque catégorie de {@link KitchenActionType},
|
||||||
|
* et ce qu'extraire pour chaque champ. Explicitement bilingue dans son
|
||||||
|
* énoncé même (plutôt que deux prompts FR/EN séparés à maintenir) — le but
|
||||||
|
* du benchmark est justement de voir si un seul prompt, sur un modèle
|
||||||
|
* multilingue, tient la route en français ET en anglais sans bascule
|
||||||
|
* explicite de langue.
|
||||||
|
*
|
||||||
|
* Exporté et réutilisé tel quel par `ollama-tech-step-poc.ts` — les deux
|
||||||
|
* moteurs LLM de ce PoC doivent tester exactement la même sémantique/tâche,
|
||||||
|
* seul le mécanisme de contrainte JSON (grammaire GBNF ici, JSON Schema
|
||||||
|
* natif côté Ollama) diffère ; dupliquer ce texte risquerait de faire
|
||||||
|
* dériver les deux prompts sans que ce soit voulu.
|
||||||
|
*/
|
||||||
|
export const SYSTEM_PROMPT = `You are a culinary instruction parser. You receive ONE recipe step, written in either French or English. Break it down into the ordered sequence of atomic actions it describes, and respond with ONLY the JSON object required by the schema — no prose, no markdown code fences, no explanation.
|
||||||
|
|
||||||
|
Action taxonomy (pick exactly one per action):
|
||||||
|
- CUT: knife work — chopping, dicing, mincing, slicing, peeling.
|
||||||
|
- COOK: applying heat to actually cook food — frying, sautéing, simmering, boiling, baking, grilling, melting.
|
||||||
|
- MIX: combining/stirring/whisking/folding ingredients together, with no heat involved.
|
||||||
|
- REST: letting something sit, cool, chill, marinate or rise without active handling.
|
||||||
|
- SEASON: adding salt, pepper, spices, herbs or condiments to flavor a dish.
|
||||||
|
- PREHEAT: bringing an oven, pan or appliance up to temperature before it is used.
|
||||||
|
- OTHER: anything not covered above (plating, straining, transferring, reserving...).
|
||||||
|
|
||||||
|
For each action extract:
|
||||||
|
- verb: the literal action verb from the source text, in its original language.
|
||||||
|
- ingredients: the ingredients this specific action applies to (empty array if none named).
|
||||||
|
- durationMinutes: a single number in minutes if a duration is stated (convert hours/seconds), otherwise null.
|
||||||
|
- temperature: the literal temperature/heat-level mention (e.g. "180°C", "feu doux", "medium heat"), otherwise null.
|
||||||
|
- utensils: any cookware/tools named for this action (empty array if none named).
|
||||||
|
|
||||||
|
Keep the actions in the order they happen in the text. A step can describe several sequential actions, even when no explicit verb names a technique (e.g. "until the butter has disappeared into the pan" means melting butter, i.e. COOK).`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Modèles recommandés
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Une des deux familles de modèles GGUF évaluées par ce PoC — voir le comparatif dans `README.md`. */
|
||||||
|
export type RecommendedModelKey = "qwen2.5-1.5b" | "llama-3.2-1b";
|
||||||
|
|
||||||
|
/** Un modèle GGUF candidat, référencé par son URI `hf:` (résolu/téléchargé par `resolveModelFile`, voir la doc "Downloading Models" de node-llama-cpp). */
|
||||||
|
interface RecommendedModel {
|
||||||
|
/** URI `hf:<repo>:<quant>` — node-llama-cpp résout et télécharge (une seule fois, mis en cache) le fichier GGUF correspondant depuis Hugging Face. */
|
||||||
|
hfUri: string;
|
||||||
|
/** Pourquoi ce modèle, en une phrase — voir aussi le comparatif détaillé dans `README.md`. */
|
||||||
|
rationale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les deux modèles recommandés pour cette tâche, choisis parmi les
|
||||||
|
* instruct GGUF ~1-1.5 Md de paramètres (assez petits pour tourner en CPU
|
||||||
|
* pur avec une latence de l'ordre de la seconde, assez récents pour bien
|
||||||
|
* suivre des instructions de structuration JSON) :
|
||||||
|
*
|
||||||
|
* - **Qwen2.5-1.5B-Instruct** (recommandation par défaut) : corpus
|
||||||
|
* d'entraînement nettement plus multilingue que la famille Llama à
|
||||||
|
* taille comparable, et meilleur suivi d'instructions de structuration
|
||||||
|
* (extraction JSON, function calling) dans les benchmarks publiés par
|
||||||
|
* Qwen. Contrairement à l'hypothèse initiale ("plus de paramètres, donc
|
||||||
|
* plus lent"), des runs antérieurs de ce PoC (Windows, backend Vulkan)
|
||||||
|
* l'ont aussi montré systématiquement PLUS RAPIDE que Llama-3.2-1B sur
|
||||||
|
* les 7 phrases de test, malgré ses ~50 % de paramètres en plus — le
|
||||||
|
* premier choix sur les deux axes ici, pas seulement sur la robustesse
|
||||||
|
* FR/EN.
|
||||||
|
* - **Llama-3.2-1B-Instruct** (alternative) : ~35 % de paramètres en
|
||||||
|
* moins, et le FR fait partie de ses langues officiellement supportées,
|
||||||
|
* mais avec un suivi d'instructions de structuration plus fragile à
|
||||||
|
* cette taille dans la pratique — et, empiriquement (voir ci-dessus), pas
|
||||||
|
* plus rapide non plus sur ce benchmark. Gardé comme point de comparaison
|
||||||
|
* plutôt que retiré : la latence relative entre les deux dépend du
|
||||||
|
* backend d'inférence (CUDA/Vulkan/CPU pur) et du matériel, un résultat
|
||||||
|
* obtenu sur une seule machine ne généralise pas forcément.
|
||||||
|
*
|
||||||
|
* Les deux sont quantisés en `Q4_K_M` — le compromis taille/qualité standard
|
||||||
|
* pour de l'inférence CPU (~4.5 bits/poids, largement suffisant pour une
|
||||||
|
* tâche d'extraction structurée, contrairement à de la génération créative
|
||||||
|
* longue où une quantisation plus fine se voit davantage).
|
||||||
|
*/
|
||||||
|
export const RECOMMENDED_MODELS: Record<RecommendedModelKey, RecommendedModel> = {
|
||||||
|
"qwen2.5-1.5b": {
|
||||||
|
hfUri: "hf:Qwen/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M",
|
||||||
|
rationale:
|
||||||
|
"Meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON — et, empiriquement sur ce benchmark, aussi la latence la plus basse malgré la taille plus grande.",
|
||||||
|
},
|
||||||
|
"llama-3.2-1b": {
|
||||||
|
hfUri: "hf:bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M",
|
||||||
|
rationale:
|
||||||
|
"Plus petit, structuration JSON moins fiable à 1B, et pas plus rapide qu'un Qwen 1.5B sur ce benchmark — conservé comme point de comparaison, pas comme choix latence.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Répertoire où les modèles GGUF téléchargés sont mis en cache — à côté de ce fichier, jamais commité (voir `.gitignore` du dossier). */
|
||||||
|
const MODELS_DIRECTORY = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "models");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout le chemin du fichier GGUF à charger : un chemin local explicite
|
||||||
|
* via `LLM_TECH_STEP_MODEL_PATH` prime toujours (utile hors-ligne, ou en CI
|
||||||
|
* où un téléchargement réseau à la volée n'est pas souhaitable) ; sinon,
|
||||||
|
* {@link RECOMMENDED_MODELS} est résolu par `resolveModelFile`, qui
|
||||||
|
* télécharge le fichier une seule fois dans {@link MODELS_DIRECTORY} puis le
|
||||||
|
* réutilise tel quel aux exécutions suivantes.
|
||||||
|
*/
|
||||||
|
async function resolveModelPath(modelKey: RecommendedModelKey): Promise<string> {
|
||||||
|
const explicitPath = process.env.LLM_TECH_STEP_MODEL_PATH;
|
||||||
|
if (explicitPath !== undefined && explicitPath.length > 0) return explicitPath;
|
||||||
|
return await resolveModelFile(RECOMMENDED_MODELS[modelKey].hfUri, MODELS_DIRECTORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LocalLlmStepAnalyzer — enrobage node-llama-cpp
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge un modèle GGUF local et l'expose comme un service d'analyse
|
||||||
|
* d'étapes de recette — vraie `class` (pas un objet littéral), même
|
||||||
|
* convention que `TechStepClassifierService` : elle possède un état réel
|
||||||
|
* (modèle chargé, contexte, grammaire compilée) coûteux à reconstruire,
|
||||||
|
* jamais recréé par appel.
|
||||||
|
*/
|
||||||
|
export class LocalLlmStepAnalyzer {
|
||||||
|
/** Instance `node-llama-cpp` — porte d'entrée vers le binding natif llama.cpp. `undefined` avant `initialize()`. */
|
||||||
|
private _llama: Awaited<ReturnType<typeof getLlama>> | undefined;
|
||||||
|
/** Modèle GGUF chargé en mémoire. `undefined` avant `initialize()`. */
|
||||||
|
private _model:
|
||||||
|
| Awaited<ReturnType<Awaited<ReturnType<typeof getLlama>>["loadModel"]>>
|
||||||
|
| undefined;
|
||||||
|
/** Contexte d'inférence (fenêtre de contexte + cache KV) dérivé de `_model`. `undefined` avant `initialize()`. */
|
||||||
|
private _context:
|
||||||
|
| Awaited<
|
||||||
|
ReturnType<
|
||||||
|
Awaited<ReturnType<Awaited<ReturnType<typeof getLlama>>["loadModel"]>>["createContext"]
|
||||||
|
>
|
||||||
|
>
|
||||||
|
| undefined;
|
||||||
|
/**
|
||||||
|
* Grammaire GBNF compilée depuis {@link KITCHEN_ACTIONS_JSON_SCHEMA} —
|
||||||
|
* compilée une seule fois, réutilisée à chaque `analyzeStep`. Typée
|
||||||
|
* explicitement via le type générique `LlamaJsonSchemaGrammar<Schema>`
|
||||||
|
* (plutôt qu'un `Awaited<ReturnType<...>>` sur la méthode générique
|
||||||
|
* `createGrammarForJsonSchema`, qui perd le type précis du schéma faute
|
||||||
|
* d'argument concret à cet endroit) pour que `grammar.parse()` renvoie un
|
||||||
|
* type déjà aligné sur {@link KitchenActionsGrammarResult}. `undefined`
|
||||||
|
* avant `initialize()`.
|
||||||
|
*/
|
||||||
|
private _grammar: LlamaJsonSchemaGrammar<typeof KITCHEN_ACTIONS_JSON_SCHEMA> | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge le modèle (téléchargement au besoin, voir {@link resolveModelPath}),
|
||||||
|
* crée son contexte d'inférence et compile la grammaire JSON — la partie
|
||||||
|
* coûteuse (souvent plusieurs secondes, dominée par le chargement des
|
||||||
|
* poids depuis disque), à faire une seule fois avant tout `analyzeStep`.
|
||||||
|
*
|
||||||
|
* Journalise chaque sous-étape (`console.info`, autorisé par
|
||||||
|
* `biome.json` — voir `suspicious.noConsole`) : cette méthode reste muette
|
||||||
|
* pendant plusieurs secondes à secondes-longues sans ça (résolution/
|
||||||
|
* téléchargement du modèle, chargement des poids, création du contexte,
|
||||||
|
* compilation de la grammaire), et rien ne dit à l'utilisateur laquelle
|
||||||
|
* de ces sous-étapes est en cours.
|
||||||
|
*/
|
||||||
|
public async initialize(modelKey: RecommendedModelKey): Promise<void> {
|
||||||
|
console.info(`[poc] résolution du modèle "${modelKey}"...`);
|
||||||
|
const modelPath = await resolveModelPath(modelKey);
|
||||||
|
console.info(`[poc] modèle : ${modelPath}`);
|
||||||
|
|
||||||
|
console.info("[poc] initialisation de node-llama-cpp...");
|
||||||
|
this._llama = await getLlama();
|
||||||
|
|
||||||
|
console.info("[poc] chargement des poids en mémoire...");
|
||||||
|
this._model = await this._llama.loadModel({ modelPath });
|
||||||
|
|
||||||
|
console.info("[poc] création du contexte d'inférence...");
|
||||||
|
this._context = await this._model.createContext({ contextSize: 4096 });
|
||||||
|
|
||||||
|
console.info("[poc] compilation de la grammaire JSON...");
|
||||||
|
this._grammar = await this._llama.createGrammarForJsonSchema(KITCHEN_ACTIONS_JSON_SCHEMA);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force un premier appel d'inférence factice, séparément de
|
||||||
|
* `initialize()` et avant tout appel mesuré par le benchmark — même rôle
|
||||||
|
* que `TechStepClassifierService.warmUp()` côté `node-nlp`
|
||||||
|
* (`tech-step-matcher.ts`) : le tout premier `session.prompt()` sur un
|
||||||
|
* contexte fraîchement créé paie un coût caché que `initialize()` ne
|
||||||
|
* couvre pas (spin-up du pool de threads llama.cpp, allocation du cache
|
||||||
|
* KV, initialisation paresseuse du tokenizer) — mesuré entre 15 et 20+
|
||||||
|
* secondes selon le modèle/matériel, contre quelques secondes pour les
|
||||||
|
* appels suivants sur la même phrase. Sans cet appel, c'est la première
|
||||||
|
* phrase du benchmark qui absorbe ce coût, faussant sa latence
|
||||||
|
* moyenne/max sans rapport avec le coût réel d'une inférence en régime
|
||||||
|
* établi.
|
||||||
|
*/
|
||||||
|
public async warmUp(): Promise<void> {
|
||||||
|
await this.analyzeStep("Faites chauffer une poêle.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyse une étape de recette et renvoie sa séquence ordonnée d'actions.
|
||||||
|
*
|
||||||
|
* Une séquence `LlamaContextSequence` dédiée est allouée pour CET appel
|
||||||
|
* puis libérée en sortie (`finally`), plutôt que de réutiliser une session
|
||||||
|
* de chat partagée : `LlamaChatSession` accumule l'historique de
|
||||||
|
* conversation à chaque `prompt()`, ce qui aurait fait grandir le contexte
|
||||||
|
* (et donc la latence mesurée) au fil des phrases du benchmark au lieu de
|
||||||
|
* mesurer chaque étape dans des conditions comparables. Le contexte par
|
||||||
|
* défaut n'autorise qu'une seule séquence active à la fois
|
||||||
|
* (`createContext()` sans `sequences` explicite) — d'où la libération
|
||||||
|
* immédiate, indispensable pour que l'appel suivant puisse en allouer une
|
||||||
|
* nouvelle.
|
||||||
|
*/
|
||||||
|
public async analyzeStep(stepText: string): Promise<RecipeStepAnalysis> {
|
||||||
|
if (this._llama === undefined || this._context === undefined || this._grammar === undefined) {
|
||||||
|
throw new Error("LocalLlmStepAnalyzer.initialize() must be awaited before analyzeStep().");
|
||||||
|
}
|
||||||
|
const context = this._context;
|
||||||
|
const grammar = this._grammar;
|
||||||
|
const sequence = context.getSequence();
|
||||||
|
try {
|
||||||
|
const session = new LlamaChatSession({
|
||||||
|
contextSequence: sequence,
|
||||||
|
systemPrompt: SYSTEM_PROMPT,
|
||||||
|
});
|
||||||
|
const response = await session.prompt(stepText, { grammar });
|
||||||
|
// La grammaire garantit un JSON syntaxiquement conforme au schéma —
|
||||||
|
// ce cast ne fait que réattacher le type nommé `KitchenActionsGrammarResult`
|
||||||
|
// (le schéma étant défini structurellement, pas de risque `any`).
|
||||||
|
const parsed = grammar.parse(response) as KitchenActionsGrammarResult;
|
||||||
|
return { originalText: stepText, actions: parsed.actions };
|
||||||
|
} finally {
|
||||||
|
await sequence.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Libère le modèle et son contexte — à appeler une fois le benchmark terminé, la mémoire native n'étant pas gérée par le GC de V8. */
|
||||||
|
public async dispose(): Promise<void> {
|
||||||
|
await this._context?.dispose();
|
||||||
|
await this._model?.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benchmark
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Imprime le détail (action/verbe/ingrédients/durée/température/ustensiles) de chaque échantillon — matière première pour comparer à l'œil avec les autres moteurs. */
|
||||||
|
function printDetailedResults(samples: readonly BenchmarkSample<RecipeStepAnalysis>[]): void {
|
||||||
|
for (const sample of samples) {
|
||||||
|
console.info(
|
||||||
|
`\n[${sample.sentence.id}] (${sample.sentence.locale}) — ${sample.latencyMs.toFixed(0)} ms`,
|
||||||
|
);
|
||||||
|
console.info(` texte : ${sample.sentence.text}`);
|
||||||
|
console.info(` attendu : ${sample.sentence.note}`);
|
||||||
|
console.table(
|
||||||
|
sample.result.actions.map((action) => ({
|
||||||
|
action: action.action,
|
||||||
|
verbe: action.verb,
|
||||||
|
ingrédients: action.ingredients.join(", "),
|
||||||
|
"durée (min)": action.durationMinutes ?? "—",
|
||||||
|
température: action.temperature ?? "—",
|
||||||
|
ustensiles: action.utensils.join(", "),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point d'entrée : charge le modèle choisi via `LLM_TECH_STEP_MODEL`
|
||||||
|
* (`"qwen2.5-1.5b"` par défaut, voir {@link RECOMMENDED_MODELS}), lance le
|
||||||
|
* benchmark sur les 7 phrases partagées (`shared/test-sentences.ts`),
|
||||||
|
* imprime les résultats détaillés puis le récapitulatif, et libère le
|
||||||
|
* modèle avant de quitter.
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const modelKey: RecommendedModelKey =
|
||||||
|
process.env.LLM_TECH_STEP_MODEL === "llama-3.2-1b" ? "llama-3.2-1b" : "qwen2.5-1.5b";
|
||||||
|
console.info(
|
||||||
|
`[poc] modèle sélectionné : ${modelKey} (${RECOMMENDED_MODELS[modelKey].rationale})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const analyzer = new LocalLlmStepAnalyzer();
|
||||||
|
const rssBeforeLoad = process.memoryUsage().rss;
|
||||||
|
// `performance.now()` plutôt que `console.time`/`console.timeEnd` — la
|
||||||
|
// config Biome du repo n'autorise que error/warn/info/debug/table/assert
|
||||||
|
// sur `console` (voir `biome.json`, `suspicious.noConsole`), pas `time`.
|
||||||
|
const loadStartedAt = performance.now();
|
||||||
|
try {
|
||||||
|
await analyzer.initialize(modelKey);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
"[poc] échec du chargement du modèle — vérifier LLM_TECH_STEP_MODEL_PATH / la connexion réseau pour le téléchargement initial",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loadDurationMs = performance.now() - loadStartedAt;
|
||||||
|
const modelRssMb = (process.memoryUsage().rss - rssBeforeLoad) / (1024 * 1024);
|
||||||
|
console.info(
|
||||||
|
`[poc] modèle chargé en ${loadDurationMs.toFixed(0)} ms (+${modelRssMb.toFixed(1)} Mo RSS)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Absorbe ici le coût caché du tout premier appel d'inférence (voir
|
||||||
|
// LocalLlmStepAnalyzer.warmUp) plutôt que de laisser la première phrase
|
||||||
|
// du benchmark le payer — sans ça, sa latence n'est pas comparable aux
|
||||||
|
// six autres.
|
||||||
|
const warmUpStartedAt = performance.now();
|
||||||
|
try {
|
||||||
|
await analyzer.warmUp();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[poc] échec du warm-up — le benchmark continue quand même", err);
|
||||||
|
}
|
||||||
|
console.info(`[poc] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const benchmarkStartedAt = performance.now();
|
||||||
|
const samples = await runBenchmark<RecipeStepAnalysis>({
|
||||||
|
logPrefix: "[poc]",
|
||||||
|
countOf: (result) => result.actions.length,
|
||||||
|
countLabel: "action(s) détectée(s)",
|
||||||
|
analyze: (sentence: BenchmarkSentence) => analyzer.analyzeStep(sentence.text),
|
||||||
|
});
|
||||||
|
console.info(
|
||||||
|
`[poc] benchmark complet en ${(performance.now() - benchmarkStartedAt).toFixed(0)} ms`,
|
||||||
|
);
|
||||||
|
printDetailedResults(samples);
|
||||||
|
printSummaryTable(samples, (result) => result.actions.length, "actions détectées");
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await analyzer.dispose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[poc] erreur lors de la libération du modèle", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMainModule(import.meta.url)) {
|
||||||
|
await main();
|
||||||
|
}
|
||||||
671
experiments/llm-tech-step-poc/src/nlp-tech-step-poc.ts
Normal file
671
experiments/llm-tech-step-poc/src/nlp-tech-step-poc.ts
Normal file
|
|
@ -0,0 +1,671 @@
|
||||||
|
/**
|
||||||
|
* PoC autonome — classifieur `node-nlp` FRAIS, entraîné directement sur la
|
||||||
|
* taxonomie à 7 catégories de {@link KitchenActionType} (partagée avec
|
||||||
|
* `llm-tech-step-poc.ts`), plutôt qu'une réutilisation de
|
||||||
|
* `TechStepClassifierService` (`apps/api/src/lib/recipe-matching/
|
||||||
|
* tech-step-matcher.ts`, taxonomie fine à ~26 techniques, DB-backed). Deux
|
||||||
|
* raisons de repartir de zéro plutôt que de réutiliser l'existant :
|
||||||
|
*
|
||||||
|
* 1. **Comparaison vraiment terme à terme** : la V1 de ce PoC comparait un
|
||||||
|
* LLM sortant du `KitchenActionType` (7 catégories) à `node-nlp` sortant
|
||||||
|
* des `TechStep` (~26 techniques) — deux taxonomies différentes rendaient
|
||||||
|
* le nombre de détections difficile à comparer directement. Entraîné ici
|
||||||
|
* sur la même taxonomie que le LLM, ses sorties sont directement
|
||||||
|
* comparables catégorie par catégorie.
|
||||||
|
* 2. **Un score de confiance EXPLOITABLE par le pipeline hybride**
|
||||||
|
* (`hybrid-tech-step-poc.ts`) : `TechStepClassifierService` masque son
|
||||||
|
* score en retombant silencieusement sur l'ancre NER dès qu'il est sous
|
||||||
|
* son seuil interne (voir sa propre doc, point 3) — utile pour son usage
|
||||||
|
* en prod, mais ça cache exactement le signal dont un pipeline hybride a
|
||||||
|
* besoin pour décider quand basculer vers le LLM. Ce classifieur-ci
|
||||||
|
* renvoie toujours le score BRUT du classifieur, jamais masqué.
|
||||||
|
*
|
||||||
|
* Même pipeline NER -> découpage en clauses -> classification par clause
|
||||||
|
* que `tech-step-matcher.ts` (même principe, implémentation propre à ce
|
||||||
|
* PoC — {@link splitIntoClauses} ici est une version simplifiée : split au
|
||||||
|
* plus proche espace du milieu de l'écart entre deux candidats, sans la
|
||||||
|
* priorité aux frontières de phrase de la version production). Aucune
|
||||||
|
* dépendance à `apps/api`/Postgres — un `TechStep.uid -> id` n'existe pas
|
||||||
|
* ici, les catégories `KitchenActionType` sont directement les noms
|
||||||
|
* d'intention node-nlp, pas de résolution DB nécessaire.
|
||||||
|
*
|
||||||
|
* Usage :
|
||||||
|
*
|
||||||
|
* ```bash
|
||||||
|
* cd experiments/llm-tech-step-poc
|
||||||
|
* pnpm install --ignore-workspace
|
||||||
|
* pnpm bench:nlp
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import { NlpManager } from "node-nlp";
|
||||||
|
import {
|
||||||
|
type BenchmarkSample,
|
||||||
|
printSummaryTable,
|
||||||
|
runBenchmark,
|
||||||
|
} from "./shared/benchmark-harness.js";
|
||||||
|
import { KitchenActionType } from "./shared/kitchen-action.js";
|
||||||
|
import { isMainModule } from "./shared/module-entry.js";
|
||||||
|
import type { BenchmarkSentence } from "./shared/test-sentences.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Corpus d'entraînement — 7 catégories, FR + EN
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Vocabulaire d'une catégorie pour une langue — mêmes noms de champs que `tech-step-training-data.ts` (`synonyms` pour la NER, `utterances` pour la classification d'intention), format familier plutôt que réinventé. */
|
||||||
|
interface CategoryLocaleData {
|
||||||
|
/** Mots/courtes expressions repérés par la NER (entités enum) — servent d'ancres pour {@link splitIntoClauses}. */
|
||||||
|
synonyms: string[];
|
||||||
|
/** Phrases complètes utilisées pour entraîner la classification d'intention — la partie qui porte vraiment le sens, au-delà du mot-clé brut. */
|
||||||
|
utterances: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le vocabulaire complet d'une catégorie de {@link KitchenActionType}, dans les deux langues. */
|
||||||
|
interface CategoryTrainingData {
|
||||||
|
action: KitchenActionType;
|
||||||
|
fr: CategoryLocaleData;
|
||||||
|
en: CategoryLocaleData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corpus volontairement compact (PoC, pas un remplacement du corpus
|
||||||
|
* production `tech-step-training-data.ts`) mais couvrant les 7 catégories
|
||||||
|
* dans les deux langues. Les synonymes préfèrent un seul mot distinctif
|
||||||
|
* ("revenir" plutôt que "faire revenir"/"faites revenir") quand c'est
|
||||||
|
* possible plutôt qu'une phrase figée : une leçon tirée du run précédent de
|
||||||
|
* ce PoC, où `apps/api`'s "faites revenir" (deux mots) ratait "faites-**les**-
|
||||||
|
* revenir" — le pronom clitique français insère un mot entre les deux et
|
||||||
|
* casse un matching de phrase contiguë. Un synonyme mono-mot comme
|
||||||
|
* "revenir" matche quel que soit ce qui le précède.
|
||||||
|
*/
|
||||||
|
const TRAINING_DATA: readonly CategoryTrainingData[] = [
|
||||||
|
{
|
||||||
|
action: KitchenActionType.CUT,
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"émincer",
|
||||||
|
"émincez",
|
||||||
|
"éminçez",
|
||||||
|
"couper",
|
||||||
|
"coupez",
|
||||||
|
"hacher",
|
||||||
|
"hachez",
|
||||||
|
"trancher",
|
||||||
|
"tranchez",
|
||||||
|
"ciseler",
|
||||||
|
"ciselez",
|
||||||
|
"éplucher",
|
||||||
|
"épluchez",
|
||||||
|
"découper",
|
||||||
|
"découpez",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"émincer finement les oignons",
|
||||||
|
"couper les légumes en petits dés",
|
||||||
|
"hacher l'ail très finement avant de l'ajouter",
|
||||||
|
"éplucher puis trancher les carottes",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: [
|
||||||
|
"dice",
|
||||||
|
"diced",
|
||||||
|
"dicing",
|
||||||
|
"chop",
|
||||||
|
"chopped",
|
||||||
|
"chopping",
|
||||||
|
"slice",
|
||||||
|
"sliced",
|
||||||
|
"slicing",
|
||||||
|
"mince",
|
||||||
|
"minced",
|
||||||
|
"mincing",
|
||||||
|
"peel",
|
||||||
|
"peeled",
|
||||||
|
"peeling",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"dice the tomatoes into small cubes",
|
||||||
|
"chop the onions finely before cooking",
|
||||||
|
"slice the carrots into thin rounds",
|
||||||
|
"peel and mince the garlic cloves",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: KitchenActionType.COOK,
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"cuire",
|
||||||
|
"cuisez",
|
||||||
|
"revenir",
|
||||||
|
"mijoter",
|
||||||
|
"mijotez",
|
||||||
|
"bouillir",
|
||||||
|
"frire",
|
||||||
|
"griller",
|
||||||
|
"grillez",
|
||||||
|
"rôtir",
|
||||||
|
"rôtissez",
|
||||||
|
"fondre",
|
||||||
|
"chauffer",
|
||||||
|
"chauffez",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"faire revenir les oignons à la poêle avec un peu d'huile",
|
||||||
|
"laisser mijoter à feu doux pendant vingt minutes",
|
||||||
|
"faire fondre le beurre jusqu'à ce qu'il disparaisse dans la poêle",
|
||||||
|
"cuire les pâtes dans une grande casserole d'eau bouillante",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: [
|
||||||
|
"cook",
|
||||||
|
"cooked",
|
||||||
|
"cooking",
|
||||||
|
"fry",
|
||||||
|
"fried",
|
||||||
|
"frying",
|
||||||
|
"simmer",
|
||||||
|
"simmered",
|
||||||
|
"simmering",
|
||||||
|
"boil",
|
||||||
|
"boiled",
|
||||||
|
"boiling",
|
||||||
|
"grill",
|
||||||
|
"grilled",
|
||||||
|
"grilling",
|
||||||
|
"roast",
|
||||||
|
"roasted",
|
||||||
|
"melt",
|
||||||
|
"melted",
|
||||||
|
"melting",
|
||||||
|
"sauté",
|
||||||
|
"sautéed",
|
||||||
|
"sear",
|
||||||
|
"seared",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"simmer everything in a saucepan over low heat",
|
||||||
|
"grill the chicken over medium-high heat",
|
||||||
|
"melt the butter in a small saucepan",
|
||||||
|
"cook the pasta in a large pot of boiling water",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: KitchenActionType.MIX,
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"mélanger",
|
||||||
|
"mélangez",
|
||||||
|
"fouetter",
|
||||||
|
"fouettez",
|
||||||
|
"incorporer",
|
||||||
|
"incorporez",
|
||||||
|
"remuer",
|
||||||
|
"remuez",
|
||||||
|
"battre",
|
||||||
|
"battez",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"mélanger la farine et le sucre dans un saladier",
|
||||||
|
"fouetter les œufs jusqu'à ce qu'ils blanchissent",
|
||||||
|
"incorporer délicatement la crème fouettée",
|
||||||
|
"remuer sans arrêt jusqu'à épaississement",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: [
|
||||||
|
"mix",
|
||||||
|
"mixed",
|
||||||
|
"mixing",
|
||||||
|
"whisk",
|
||||||
|
"whisked",
|
||||||
|
"whisking",
|
||||||
|
"fold",
|
||||||
|
"folded",
|
||||||
|
"folding",
|
||||||
|
"stir",
|
||||||
|
"stirred",
|
||||||
|
"stirring",
|
||||||
|
"combine",
|
||||||
|
"combined",
|
||||||
|
"beat",
|
||||||
|
"beaten",
|
||||||
|
"beating",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"whisk the eggs and sugar together until pale and fluffy",
|
||||||
|
"fold in the sifted flour gently",
|
||||||
|
"stir constantly until the mixture thickens",
|
||||||
|
"combine all the dry ingredients in a bowl",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: KitchenActionType.REST,
|
||||||
|
fr: {
|
||||||
|
synonyms: ["reposer", "reposez", "mariner", "marinez", "refroidir", "refroidissez"],
|
||||||
|
utterances: [
|
||||||
|
"laisser reposer la pâte pendant trente minutes",
|
||||||
|
"laisser mariner la viande toute une nuit au réfrigérateur",
|
||||||
|
"laisser refroidir avant de découper",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: [
|
||||||
|
"rest",
|
||||||
|
"rested",
|
||||||
|
"resting",
|
||||||
|
"marinate",
|
||||||
|
"marinated",
|
||||||
|
"marinating",
|
||||||
|
"chill",
|
||||||
|
"chilled",
|
||||||
|
"chilling",
|
||||||
|
"cool",
|
||||||
|
"cooled",
|
||||||
|
"cooling",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"let the dough rest for thirty minutes",
|
||||||
|
"marinate the chicken in the fridge overnight",
|
||||||
|
"let it cool completely before slicing",
|
||||||
|
"let it rest for a few minutes before serving",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: KitchenActionType.SEASON,
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"assaisonner",
|
||||||
|
"assaisonnez",
|
||||||
|
"saler",
|
||||||
|
"salez",
|
||||||
|
"poivrer",
|
||||||
|
"poivrez",
|
||||||
|
"épicer",
|
||||||
|
"épicez",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"assaisonner avec du sel et du poivre",
|
||||||
|
"saler et poivrer selon le goût",
|
||||||
|
"épicer généreusement avant de servir",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: [
|
||||||
|
"season",
|
||||||
|
"seasoned",
|
||||||
|
"seasoning",
|
||||||
|
"salt",
|
||||||
|
"salted",
|
||||||
|
"pepper",
|
||||||
|
"peppered",
|
||||||
|
"spice",
|
||||||
|
"spiced",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"season with salt and pepper",
|
||||||
|
"add spices to taste",
|
||||||
|
"salt and pepper generously before cooking",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: KitchenActionType.PREHEAT,
|
||||||
|
fr: {
|
||||||
|
synonyms: ["préchauffer", "préchauffez", "préchauffage"],
|
||||||
|
utterances: [
|
||||||
|
"préchauffer le four à cent quatre-vingts degrés",
|
||||||
|
"préchauffer la poêle avant d'ajouter l'huile",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: ["preheat", "preheated", "preheating"],
|
||||||
|
utterances: [
|
||||||
|
"preheat the oven to 350 degrees",
|
||||||
|
"preheat the pan over medium heat before adding oil",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: KitchenActionType.OTHER,
|
||||||
|
fr: {
|
||||||
|
synonyms: [
|
||||||
|
"réserver",
|
||||||
|
"réservez",
|
||||||
|
"égoutter",
|
||||||
|
"égouttez",
|
||||||
|
"dresser",
|
||||||
|
"dressez",
|
||||||
|
"servir",
|
||||||
|
"servez",
|
||||||
|
"transférer",
|
||||||
|
"transférez",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"réserver de côté pendant la préparation du reste",
|
||||||
|
"égoutter les pâtes en gardant un peu d'eau de cuisson",
|
||||||
|
"dresser harmonieusement dans l'assiette",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
synonyms: [
|
||||||
|
"set aside",
|
||||||
|
"drain",
|
||||||
|
"drained",
|
||||||
|
"draining",
|
||||||
|
"plate",
|
||||||
|
"plated",
|
||||||
|
"plating",
|
||||||
|
"serve",
|
||||||
|
"served",
|
||||||
|
"transfer",
|
||||||
|
"transferred",
|
||||||
|
"pat dry",
|
||||||
|
],
|
||||||
|
utterances: [
|
||||||
|
"set it aside for later use",
|
||||||
|
"drain the pasta reserving some cooking water",
|
||||||
|
"pat the chicken thighs dry with paper towel",
|
||||||
|
"transfer everything to a serving dish",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NER -> découpage en clauses (implémentation propre à ce PoC, simplifiée)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Une mention candidate d'une catégorie, trouvée par NER — le matériau brut dont {@link splitIntoClauses} découpe des clauses. */
|
||||||
|
interface CategoryCandidate {
|
||||||
|
action: KitchenActionType;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Une clause découpée autour d'un candidat (ou l'unique clause "tout le texte" si aucun candidat n'a été trouvé). */
|
||||||
|
interface StepClause {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
anchor: CategoryCandidate | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point de coupe entre deux candidats consécutifs — l'espace le plus proche
|
||||||
|
* du milieu de l'écart `[gapStart, gapEnd)`, ou le milieu brut si l'écart ne
|
||||||
|
* contient aucun espace. Version simplifiée de l'équivalent
|
||||||
|
* `tech-step-matcher.ts` : pas de priorité aux frontières de phrase, un
|
||||||
|
* compromis PoC assumé (voir le doc-comment en tête de fichier) — un span
|
||||||
|
* de clause légèrement moins net qu'en production, mais qui ne coupe jamais
|
||||||
|
* un mot en deux.
|
||||||
|
*/
|
||||||
|
function findGapSplitPoint(text: string, gapStart: number, gapEnd: number): number {
|
||||||
|
if (gapStart >= gapEnd) return gapStart;
|
||||||
|
const midpoint = Math.floor((gapStart + gapEnd) / 2);
|
||||||
|
let best: number | null = null;
|
||||||
|
let bestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
for (let i = gapStart; i < gapEnd; i++) {
|
||||||
|
if (!/\s/.test(text[i] ?? "")) continue;
|
||||||
|
const distance = Math.abs(i - midpoint);
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
best = i;
|
||||||
|
bestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best ?? midpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Découpe `text` en clauses autour de `candidates`, une clause par
|
||||||
|
* candidat — même principe que `tech-step-matcher.ts` : zéro candidat -> tout
|
||||||
|
* le texte est une clause sans ancre ; un candidat -> tout le texte est une
|
||||||
|
* clause avec cette ancre ; deux ou plus -> une clause par candidat, coupée
|
||||||
|
* à {@link findGapSplitPoint} entre chaque paire consécutive.
|
||||||
|
*/
|
||||||
|
function splitIntoClauses(text: string, candidates: readonly CategoryCandidate[]): StepClause[] {
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return [{ start: 0, end: text.length, anchor: null }];
|
||||||
|
}
|
||||||
|
const sorted = [...candidates].sort((a, b) => a.start - b.start);
|
||||||
|
const [first, ...rest] = sorted;
|
||||||
|
if (first === undefined) {
|
||||||
|
return [{ start: 0, end: text.length, anchor: null }];
|
||||||
|
}
|
||||||
|
const clauses: StepClause[] = [];
|
||||||
|
let clauseStart = 0;
|
||||||
|
let anchor = first;
|
||||||
|
for (const next of rest) {
|
||||||
|
const splitPoint = findGapSplitPoint(text, anchor.end, next.start);
|
||||||
|
clauses.push({ start: clauseStart, end: splitPoint, anchor });
|
||||||
|
clauseStart = splitPoint;
|
||||||
|
anchor = next;
|
||||||
|
}
|
||||||
|
clauses.push({ start: clauseStart, end: text.length, anchor });
|
||||||
|
return clauses;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NlpTechStepClassifier
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Une action détectée dans une clause, avec le score BRUT du classifieur — jamais masqué par un repli silencieux, voir le doc-comment en tête de fichier (point 2). */
|
||||||
|
export interface NlpActionMatch {
|
||||||
|
action: KitchenActionType;
|
||||||
|
/** Score du classifieur `node-nlp` pour cette clause, `[0, 1]` — `0` quand le classifieur n'a rien reconnu du tout (`intent === "None"`) et qu'aucune ancre NER n'existe pour retomber dessus. */
|
||||||
|
confidence: number;
|
||||||
|
/** Mot-clé ayant ancré cette clause (le texte de l'ancre NER), ou le texte de la clause entière si aucune ancre n'existe. */
|
||||||
|
matchedText: string;
|
||||||
|
/** Texte complet de la clause classifiée. */
|
||||||
|
clauseText: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
contextStart: number;
|
||||||
|
contextEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résultat complet de l'analyse d'une étape par le classifieur NLP. */
|
||||||
|
export interface NlpStepAnalysis {
|
||||||
|
originalText: string;
|
||||||
|
matches: NlpActionMatch[];
|
||||||
|
/**
|
||||||
|
* Confiance au niveau de l'étape entière — le MINIMUM des confidences de
|
||||||
|
* ses clauses (une étape n'est fiable que si TOUTES ses clauses le sont),
|
||||||
|
* ou `0` si aucune clause n'a produit de match. C'est ce champ que
|
||||||
|
* `hybrid-tech-step-poc.ts` compare à son seuil pour décider d'escalader
|
||||||
|
* vers le LLM.
|
||||||
|
*/
|
||||||
|
overallConfidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `value` est-elle une des 7 valeurs de {@link KitchenActionType} ? — `node-nlp` renvoie l'intent sous forme de `string` brute, à valider avant de la traiter comme une vraie catégorie. */
|
||||||
|
function isKitchenActionType(value: string): value is KitchenActionType {
|
||||||
|
return (Object.values(KitchenActionType) as string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifieur `node-nlp` frais pour ce PoC — vraie `class` (pas un objet
|
||||||
|
* littéral), même convention que `TechStepClassifierService`/
|
||||||
|
* `LocalLlmStepAnalyzer` : possède un état réel (modèle entraîné),
|
||||||
|
* coûteux à reconstruire, jamais recréé par appel.
|
||||||
|
*/
|
||||||
|
export class NlpTechStepClassifier {
|
||||||
|
private readonly _manager: NlpManager;
|
||||||
|
/** Mémoïse l'entraînement — `undefined` jusqu'au premier appel, chaque appelant (concurrent ou non) attend ensuite la même promesse plutôt que de ré-entraîner. */
|
||||||
|
private _trained: Promise<void> | undefined;
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
this._manager = new NlpManager({
|
||||||
|
languages: ["fr", "en"],
|
||||||
|
forceNER: true,
|
||||||
|
nlu: { log: false },
|
||||||
|
// Seuil `1` (exact, après normalisation casse/accents/stemming de
|
||||||
|
// node-nlp) plutôt que le défaut `0.8` (fuzzy/Levenshtein) — même
|
||||||
|
// raisonnement que `tech-step-matcher.ts` : la précision de la NER
|
||||||
|
// compte plus que son rappel ici, elle ne fait que proposer des
|
||||||
|
// candidats de découpage, c'est la classification d'intention qui
|
||||||
|
// doit vraiment avoir raison.
|
||||||
|
ner: { threshold: 1 },
|
||||||
|
// Jamais de persistance sur disque — le corpus en code est la seule
|
||||||
|
// source de vérité, un modèle stale sur disque masquerait
|
||||||
|
// silencieusement une mise à jour du corpus.
|
||||||
|
autoSave: false,
|
||||||
|
autoLoad: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force l'entraînement plus l'initialisation paresseuse de node-nlp (stemmers/tokenizers par langue, chargés au premier `process()` réel) à se faire maintenant, avant tout appel mesuré par le benchmark. */
|
||||||
|
public async warmUp(): Promise<void> {
|
||||||
|
await this.analyzeStep("Faites chauffer une poêle.", "fr");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Analyse une étape et renvoie ses matches + sa confiance globale. Voir le doc-comment en tête de fichier pour le pipeline NER -> clauses -> classification. */
|
||||||
|
public async analyzeStep(text: string, locale: "fr" | "en"): Promise<NlpStepAnalysis> {
|
||||||
|
await this._ensureTrained();
|
||||||
|
if (text.trim().length === 0) {
|
||||||
|
return { originalText: text, matches: [], overallConfidence: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nerResult = await this._manager.process(locale, text);
|
||||||
|
const candidates: CategoryCandidate[] = nerResult.entities
|
||||||
|
.filter((entity) => entity.type === "enum" && isKitchenActionType(entity.entity))
|
||||||
|
.map((entity) => ({
|
||||||
|
// Le filtre ci-dessus garantit `isKitchenActionType(entity.entity)`
|
||||||
|
// — cast plutôt que refiltrer, `Array.prototype.filter` n'affine
|
||||||
|
// pas le type de `entity.entity` (`string`) tout seul.
|
||||||
|
action: entity.entity as KitchenActionType,
|
||||||
|
start: entity.start,
|
||||||
|
// node-nlp's `end` est inclusif — `+1` convertit vers `[start, end)`.
|
||||||
|
end: entity.end + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const clauses = splitIntoClauses(text, candidates);
|
||||||
|
const matches: NlpActionMatch[] = [];
|
||||||
|
for (const clause of clauses) {
|
||||||
|
const clauseText = text.slice(clause.start, clause.end).trim();
|
||||||
|
let action: KitchenActionType;
|
||||||
|
let confidence: number;
|
||||||
|
if (clauseText.length === 0) {
|
||||||
|
action = clause.anchor?.action ?? KitchenActionType.OTHER;
|
||||||
|
confidence = 0;
|
||||||
|
} else {
|
||||||
|
const result = await this._manager.process(locale, clauseText);
|
||||||
|
if (result.intent !== "None" && isKitchenActionType(result.intent)) {
|
||||||
|
action = result.intent;
|
||||||
|
confidence = result.score;
|
||||||
|
} else {
|
||||||
|
// Contrairement à `TechStepClassifierService`, pas de repli
|
||||||
|
// silencieux sur un score "rescapé" : le score `0` reflète
|
||||||
|
// honnêtement qu'aucune classification fiable n'a eu lieu,
|
||||||
|
// l'ancre NER sert seulement à choisir QUELLE catégorie
|
||||||
|
// afficher, pas à masquer que la confiance réelle est nulle.
|
||||||
|
action = clause.anchor?.action ?? KitchenActionType.OTHER;
|
||||||
|
confidence = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const matchedText =
|
||||||
|
clause.anchor !== null ? text.slice(clause.anchor.start, clause.anchor.end) : clauseText;
|
||||||
|
matches.push({
|
||||||
|
action,
|
||||||
|
confidence,
|
||||||
|
matchedText,
|
||||||
|
clauseText,
|
||||||
|
start: clause.anchor?.start ?? clause.start,
|
||||||
|
end: clause.anchor?.end ?? clause.end,
|
||||||
|
contextStart: clause.start,
|
||||||
|
contextEnd: clause.end,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const overallConfidence =
|
||||||
|
matches.length === 0 ? 0 : Math.min(...matches.map((match) => match.confidence));
|
||||||
|
return { originalText: text, matches, overallConfidence };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _ensureTrained(): Promise<void> {
|
||||||
|
if (this._trained === undefined) {
|
||||||
|
this._trained = this._train();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this._trained;
|
||||||
|
} catch (err) {
|
||||||
|
// Un entraînement raté doit pouvoir être retenté au prochain appel,
|
||||||
|
// pas laisser tout appel futur échouer contre la même promesse figée.
|
||||||
|
this._trained = undefined;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _train(): Promise<void> {
|
||||||
|
for (const entry of TRAINING_DATA) {
|
||||||
|
for (const [locale, data] of [
|
||||||
|
["fr", entry.fr],
|
||||||
|
["en", entry.en],
|
||||||
|
] as const) {
|
||||||
|
if (data.synonyms.length > 0) {
|
||||||
|
this._manager.addNamedEntityText(entry.action, entry.action, [locale], data.synonyms);
|
||||||
|
}
|
||||||
|
for (const utterance of data.utterances) {
|
||||||
|
this._manager.addDocument(locale, utterance, entry.action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this._manager.train();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benchmark
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Imprime le détail (action/confiance/mot-clé/clause) de chaque échantillon — matière première pour comparer à l'œil avec le LLM. */
|
||||||
|
function printDetailedResults(samples: readonly BenchmarkSample<NlpStepAnalysis>[]): void {
|
||||||
|
for (const sample of samples) {
|
||||||
|
console.info(
|
||||||
|
`\n[${sample.sentence.id}] (${sample.sentence.locale}) — ${sample.latencyMs.toFixed(0)} ms, confiance globale ${sample.result.overallConfidence.toFixed(2)}`,
|
||||||
|
);
|
||||||
|
console.info(` texte : ${sample.sentence.text}`);
|
||||||
|
console.info(` attendu : ${sample.sentence.note}`);
|
||||||
|
console.table(
|
||||||
|
sample.result.matches.map((match) => ({
|
||||||
|
action: match.action,
|
||||||
|
confiance: match.confidence.toFixed(2),
|
||||||
|
mot_clé: match.matchedText,
|
||||||
|
clause: match.clauseText,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const classifier = new NlpTechStepClassifier();
|
||||||
|
|
||||||
|
console.info("[nlp] warm-up (entraînement + init paresseuse de node-nlp)...");
|
||||||
|
const warmUpStartedAt = performance.now();
|
||||||
|
await classifier.warmUp();
|
||||||
|
console.info(`[nlp] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`);
|
||||||
|
|
||||||
|
const benchmarkStartedAt = performance.now();
|
||||||
|
const samples = await runBenchmark<NlpStepAnalysis>({
|
||||||
|
logPrefix: "[nlp]",
|
||||||
|
countOf: (result) => result.matches.length,
|
||||||
|
countLabel: "action(s) détectée(s)",
|
||||||
|
analyze: (sentence: BenchmarkSentence) =>
|
||||||
|
classifier.analyzeStep(sentence.text, sentence.locale),
|
||||||
|
});
|
||||||
|
console.info(
|
||||||
|
`[nlp] benchmark complet en ${(performance.now() - benchmarkStartedAt).toFixed(0)} ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
printDetailedResults(samples);
|
||||||
|
printSummaryTable(samples, (result) => result.matches.length, "actions détectées");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMainModule(import.meta.url)) {
|
||||||
|
await main();
|
||||||
|
}
|
||||||
382
experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts
Normal file
382
experiments/llm-tech-step-poc/src/ollama-tech-step-poc.ts
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
/**
|
||||||
|
* PoC autonome — même tâche que `llm-tech-step-poc.ts` (extraction JSON
|
||||||
|
* contrainte par schéma d'une séquence d'actions culinaires), même
|
||||||
|
* `SYSTEM_PROMPT` (importé tel quel, voir sa doc), mais via
|
||||||
|
* [Ollama](https://ollama.com/) au lieu de `node-llama-cpp` — un troisième
|
||||||
|
* point de comparaison, architecturalement différent des deux autres
|
||||||
|
* moteurs LLM/NLP de ce PoC plutôt qu'une simple redite :
|
||||||
|
*
|
||||||
|
* - **`node-llama-cpp`** charge le binding natif llama.cpp DANS ce process
|
||||||
|
* Node (mêmes poids, même mémoire, même thread pool que le script).
|
||||||
|
* - **Ollama** est un serveur HTTP local **séparé** (`ollama serve`, lancé
|
||||||
|
* par l'app de bureau ou en CLI) — ce script n'est qu'un client HTTP fin
|
||||||
|
* (`ollama` sur npm, aucune dépendance native, aucun binding à compiler à
|
||||||
|
* l'installation) qui lui parle en local (`http://127.0.0.1:11434` par
|
||||||
|
* défaut). Conséquences directes, documentées où elles s'appliquent :
|
||||||
|
* - Pas de téléchargement/cache GGUF géré par ce projet — Ollama gère ses
|
||||||
|
* propres modèles (`~/.ollama/models`), récupérés via `ollama.pull()`
|
||||||
|
* (voir {@link OllamaStepAnalyzer.initialize}).
|
||||||
|
* - **Le delta de RSS de ce process ne mesure RIEN d'utile ici** :
|
||||||
|
* l'inférence tourne dans le process `ollama serve`, pas dans celui-ci
|
||||||
|
* — contrairement à `node-llama-cpp`, où le binding natif partage la
|
||||||
|
* mémoire du process Node. Cette colonne du récapitulatif reste
|
||||||
|
* affichée (même harness que les deux autres moteurs) mais est à
|
||||||
|
* ignorer pour ce script, voir `README.md`.
|
||||||
|
* - Nécessite Ollama installé et **son serveur déjà lancé** en dehors de
|
||||||
|
* ce script (pas de "just works" comme le binding embarqué) —
|
||||||
|
* {@link OllamaStepAnalyzer.initialize} échoue avec un message explicite
|
||||||
|
* si le serveur n'est pas joignable plutôt qu'une erreur `fetch` brute.
|
||||||
|
* - Le schéma JSON imposé au modèle (`format`, voir
|
||||||
|
* {@link KITCHEN_ACTIONS_JSON_SCHEMA}) accepte du JSON Schema standard
|
||||||
|
* (`type: ["string", "null"]` pour un champ nullable) — plus simple que
|
||||||
|
* le détour `oneOf: [{type:"null"}, {type:"..."}]` qu'exige la
|
||||||
|
* grammaire GBNF de node-llama-cpp (voir `llm-tech-step-poc.ts`), un
|
||||||
|
* autre point de comparaison entre les deux mécanismes de contrainte.
|
||||||
|
*
|
||||||
|
* Usage : voir `README.md`. En bref :
|
||||||
|
*
|
||||||
|
* ```bash
|
||||||
|
* ollama serve # dans un terminal séparé, si pas déjà lancé
|
||||||
|
* cd experiments/llm-tech-step-poc
|
||||||
|
* pnpm install --ignore-workspace
|
||||||
|
* pnpm bench:ollama
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import { Ollama } from "ollama";
|
||||||
|
import { SYSTEM_PROMPT } from "./llm-tech-step-poc.js";
|
||||||
|
import {
|
||||||
|
type BenchmarkSample,
|
||||||
|
printSummaryTable,
|
||||||
|
runBenchmark,
|
||||||
|
} from "./shared/benchmark-harness.js";
|
||||||
|
import { KitchenActionType, type RecipeStepAnalysis } from "./shared/kitchen-action.js";
|
||||||
|
import { isMainModule } from "./shared/module-entry.js";
|
||||||
|
import type { BenchmarkSentence } from "./shared/test-sentences.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schéma JSON — passé tel quel à Ollama via `format`
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schéma JSON standard (pas de dialecte GBNF-spécifique) — Ollama valide/
|
||||||
|
* contraint la génération directement contre ce schéma via son paramètre
|
||||||
|
* `format`. Champ à champ, en miroir strict de `KitchenAction`
|
||||||
|
* (`shared/kitchen-action.ts`), même remarque que côté `node-llama-cpp` :
|
||||||
|
* ça n'impose qu'une SYNTAXE JSON valide, jamais la justesse sémantique du
|
||||||
|
* contenu — c'est {@link SYSTEM_PROMPT} qui porte la sémantique.
|
||||||
|
*/
|
||||||
|
const KITCHEN_ACTION_JSON_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
action: { type: "string", enum: Object.values(KitchenActionType) },
|
||||||
|
verb: { type: "string" },
|
||||||
|
ingredients: { type: "array", items: { type: "string" } },
|
||||||
|
durationMinutes: { type: ["number", "null"] },
|
||||||
|
temperature: { type: ["string", "null"] },
|
||||||
|
utensils: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
required: ["action", "verb", "ingredients", "durationMinutes", "temperature", "utensils"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Racine du schéma — même choix qu'en `node-llama-cpp` (`{ actions: [...] }` plutôt qu'un tableau nu), `originalText` volontairement absent, voir `llm-tech-step-poc.ts` pour le raisonnement complet. */
|
||||||
|
const KITCHEN_ACTIONS_JSON_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
actions: { type: "array", items: KITCHEN_ACTION_JSON_SCHEMA },
|
||||||
|
},
|
||||||
|
required: ["actions"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Forme attendue du JSON renvoyé par Ollama (`response.message.content`, une chaîne à parser) une fois conforme à {@link KITCHEN_ACTIONS_JSON_SCHEMA}. */
|
||||||
|
interface KitchenActionsSchemaResult {
|
||||||
|
actions: RecipeStepAnalysis["actions"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Modèles recommandés
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Mêmes deux familles de modèles que `llm-tech-step-poc.ts` (voir son comparatif) — pour rester comparable, référencées ici par leur tag Ollama plutôt qu'une URI `hf:`. */
|
||||||
|
export type RecommendedOllamaModelKey = "qwen2.5-1.5b" | "llama-3.2-1b";
|
||||||
|
|
||||||
|
interface RecommendedOllamaModel {
|
||||||
|
/** Tag tel qu'Ollama le résout (`ollama pull <tag>`) — voir https://ollama.com/library. */
|
||||||
|
tag: string;
|
||||||
|
rationale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECOMMENDED_MODELS: Record<RecommendedOllamaModelKey, RecommendedOllamaModel> = {
|
||||||
|
"qwen2.5-1.5b": {
|
||||||
|
tag: "qwen2.5:1.5b",
|
||||||
|
rationale:
|
||||||
|
"Même choix par défaut que côté node-llama-cpp : meilleure robustesse multilingue FR/EN et meilleur suivi d'instructions de structuration JSON.",
|
||||||
|
},
|
||||||
|
"llama-3.2-1b": {
|
||||||
|
tag: "llama3.2:1b",
|
||||||
|
rationale:
|
||||||
|
"Alternative plus légère — voir le comparatif détaillé et les résultats empiriques dans le README et dans llm-tech-step-poc.ts.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_OLLAMA_HOST = "http://127.0.0.1:11434";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OllamaStepAnalyzer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client Ollama enrobé comme service d'analyse d'étapes de recette — vraie
|
||||||
|
* `class` (pas un objet littéral), même convention que
|
||||||
|
* `LocalLlmStepAnalyzer`/`NlpTechStepClassifier` : possède un état réel (le
|
||||||
|
* client HTTP, le tag du modèle sélectionné), même si ici l'état lourd
|
||||||
|
* (les poids du modèle) vit dans le process `ollama serve` séparé, pas
|
||||||
|
* dans cette instance.
|
||||||
|
*/
|
||||||
|
export class OllamaStepAnalyzer {
|
||||||
|
/** Nombre de tentatives avant d'abandonner sur une réponse JSON invalide — voir le doc-comment de {@link OllamaStepAnalyzer.analyzeStep}. */
|
||||||
|
private static readonly _MAX_PARSE_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
private readonly _client: Ollama;
|
||||||
|
private readonly _host: string;
|
||||||
|
/** Tag du modèle une fois résolu/pull par `initialize()`. `undefined` avant. */
|
||||||
|
private _modelTag: string | undefined;
|
||||||
|
|
||||||
|
public constructor(host: string = DEFAULT_OLLAMA_HOST) {
|
||||||
|
this._host = host;
|
||||||
|
this._client = new Ollama({ host });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie/télécharge le modèle (`ollama pull`, no-op quasi instantané si
|
||||||
|
* déjà présent localement — Ollama compare les manifestes de couches
|
||||||
|
* avant de retélécharger quoi que ce soit) et journalise la progression
|
||||||
|
* par palier de statut plutôt que de rester muet le temps du
|
||||||
|
* téléchargement (potentiellement plusieurs centaines de Mo au premier
|
||||||
|
* pull d'un modèle).
|
||||||
|
*
|
||||||
|
* Échoue avec un message explicite (plutôt que l'erreur `fetch` brute
|
||||||
|
* remontée par `ollama-js`) si le serveur Ollama n'est pas joignable —
|
||||||
|
* contrairement à `node-llama-cpp`, ce PoC dépend d'un process externe
|
||||||
|
* que ce script ne lance pas lui-même.
|
||||||
|
*/
|
||||||
|
public async initialize(modelKey: RecommendedOllamaModelKey): Promise<void> {
|
||||||
|
const tag = RECOMMENDED_MODELS[modelKey].tag;
|
||||||
|
console.info(`[ollama] vérification/pull du modèle "${tag}" sur ${this._host}...`);
|
||||||
|
try {
|
||||||
|
await this._ensureModelPulled(tag);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
`OllamaStepAnalyzer: impossible de joindre Ollama sur ${this._host} — le serveur est-il lancé (\`ollama serve\`, ou l'app de bureau Ollama) ?`,
|
||||||
|
{ cause: err },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this._modelTag = tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force un premier appel factice — même rôle que le warm-up des deux autres moteurs : le premier vrai appel `chat()` déclenche le chargement des poids en mémoire côté serveur Ollama, un coût cependant nettement moins visible ici qu'avec node-llama-cpp car mutualisé/mis en cache par le serveur entre plusieurs process clients. */
|
||||||
|
public async warmUp(): Promise<void> {
|
||||||
|
await this.analyzeStep("Faites chauffer une poêle.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyse une étape de recette et renvoie sa séquence ordonnée d'actions,
|
||||||
|
* via `ollama.chat()` contraint par {@link KITCHEN_ACTIONS_JSON_SCHEMA}.
|
||||||
|
*
|
||||||
|
* Réessaie jusqu'à {@link _MAX_PARSE_ATTEMPTS} fois si la réponse ne
|
||||||
|
* parse pas en JSON valide — un échec bien réel et reproduit en
|
||||||
|
* pratique, surtout sur les petits modèles (`qwen2.5:0.5b`,
|
||||||
|
* `smollm2:360m`...) face aux phrases longues de ce PoC
|
||||||
|
* (`fr-concat-volumetrie`, `fr-recette-complete`...) : le modèle part en
|
||||||
|
* boucle de répétition dans le tableau `actions` et n'atteint jamais
|
||||||
|
* l'accolade fermante avant la limite de tokens
|
||||||
|
* (`response.done_reason === "length"`, contenu de plusieurs dizaines de
|
||||||
|
* milliers de caractères observé en pratique). La grammaire imposée par
|
||||||
|
* `format` contraint la SYNTAXE token par token, elle ne borne pas la
|
||||||
|
* LONGUEUR du tableau — rien ne l'empêche de continuer à générer des
|
||||||
|
* éléments indéfiniment.
|
||||||
|
*
|
||||||
|
* `repeat_penalty`/`num_predict` réduisent nettement l'ampleur du
|
||||||
|
* dérapage (÷18 observé en pratique sur le pire cas) sans l'éliminer à
|
||||||
|
* coup sûr sur un modèle assez faible — d'où le retry, avec une
|
||||||
|
* température légèrement relevée à partir de la 2e tentative : à
|
||||||
|
* température 0 stricte, retenter avec des paramètres identiques peut
|
||||||
|
* reproduire l'échec (déterminisme), une température non nulle donne une
|
||||||
|
* vraie chance de sortir de la boucle.
|
||||||
|
*/
|
||||||
|
public async analyzeStep(stepText: string): Promise<RecipeStepAnalysis> {
|
||||||
|
if (this._modelTag === undefined) {
|
||||||
|
throw new Error("OllamaStepAnalyzer.initialize() must be awaited before analyzeStep().");
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastParseError: unknown;
|
||||||
|
let lastRawContent = "";
|
||||||
|
for (let attempt = 1; attempt <= OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS; attempt++) {
|
||||||
|
const response = await this._client.chat({
|
||||||
|
model: this._modelTag,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: SYSTEM_PROMPT },
|
||||||
|
{ role: "user", content: stepText },
|
||||||
|
],
|
||||||
|
format: KITCHEN_ACTIONS_JSON_SCHEMA,
|
||||||
|
options: {
|
||||||
|
// Température 0 sur la 1re tentative — génération déterministe,
|
||||||
|
// cohérent avec l'usage d'un schéma imposé : on veut la sortie la
|
||||||
|
// plus prévisible possible pour ce qui reste discrétionnaire (le
|
||||||
|
// contenu, pas la syntaxe). Relevée légèrement sur les tentatives
|
||||||
|
// suivantes uniquement, voir le doc-comment ci-dessus.
|
||||||
|
temperature: attempt === 1 ? 0 : 0.3,
|
||||||
|
// Décourage la boucle de répétition qui cause l'essentiel des
|
||||||
|
// échecs de parsing observés (voir doc-comment) — 1.3 plutôt que
|
||||||
|
// le défaut ~1.1 d'Ollama, choisi empiriquement contre le pire
|
||||||
|
// cas reproduit (phrase longue + petit modèle).
|
||||||
|
repeat_penalty: 1.3,
|
||||||
|
// Borne le dégât en cas de dérapage malgré repeat_penalty
|
||||||
|
// (arrête la génération avant plusieurs dizaines de milliers de
|
||||||
|
// caractères inutiles) sans pénaliser les cas normaux — même la
|
||||||
|
// phrase la plus longue de ce PoC (recette concaténée, jusqu'à
|
||||||
|
// une quinzaine d'actions) tient largement dans cette limite une
|
||||||
|
// fois correctement formée.
|
||||||
|
num_predict: 2048,
|
||||||
|
},
|
||||||
|
stream: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(response.message.content) as KitchenActionsSchemaResult;
|
||||||
|
return { originalText: stepText, actions: parsed.actions };
|
||||||
|
} catch (err) {
|
||||||
|
lastParseError = err;
|
||||||
|
lastRawContent = response.message.content;
|
||||||
|
if (attempt < OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS) {
|
||||||
|
console.error(
|
||||||
|
`[ollama] réponse JSON invalide (tentative ${attempt}/${OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS}, ${response.message.content.length} caractères, done_reason="${response.done_reason}") — nouvelle tentative...`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`OllamaStepAnalyzer: réponse JSON invalide malgré le schéma imposé, après ${OllamaStepAnalyzer._MAX_PARSE_ATTEMPTS} tentatives — dernière réponse (${lastRawContent.length} caractères) : "${lastRawContent.slice(0, 500)}${lastRawContent.length > 500 ? "..." : ""}"`,
|
||||||
|
{ cause: lastParseError },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Décharge le modèle de la mémoire du serveur Ollama (`keep_alive: 0`) —
|
||||||
|
* best effort, purement pour ne pas laisser le modèle chargé
|
||||||
|
* indéfiniment après ce benchmark : `ollama serve` tourne indépendamment
|
||||||
|
* de ce script (pas lancé ni arrêté par lui), donc rien d'autre à
|
||||||
|
* libérer côté process Node.
|
||||||
|
*/
|
||||||
|
public async dispose(): Promise<void> {
|
||||||
|
if (this._modelTag === undefined) return;
|
||||||
|
try {
|
||||||
|
await this._client.chat({ model: this._modelTag, messages: [], keep_alive: 0 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ollama] échec du déchargement du modèle (non bloquant)", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lance un `pull` en streaming et journalise chaque changement de statut (`pulling manifest`, `downloading`, `verifying sha256 digest`...) avec le pourcentage quand Ollama le fournit. */
|
||||||
|
private async _ensureModelPulled(tag: string): Promise<void> {
|
||||||
|
const progress = await this._client.pull({ model: tag, stream: true });
|
||||||
|
let lastStatus = "";
|
||||||
|
for await (const part of progress) {
|
||||||
|
if (part.status === lastStatus) continue;
|
||||||
|
lastStatus = part.status;
|
||||||
|
const percent =
|
||||||
|
part.completed !== undefined && part.total !== undefined && part.total > 0
|
||||||
|
? ` (${Math.round((part.completed / part.total) * 100)}%)`
|
||||||
|
: "";
|
||||||
|
console.info(`[ollama] ${part.status}${percent}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benchmark
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Imprime le détail de chaque échantillon — même format que `llm-tech-step-poc.ts`, pour comparer les deux moteurs LLM à l'œil ligne à ligne. */
|
||||||
|
function printDetailedResults(samples: readonly BenchmarkSample<RecipeStepAnalysis>[]): void {
|
||||||
|
for (const sample of samples) {
|
||||||
|
console.info(
|
||||||
|
`\n[${sample.sentence.id}] (${sample.sentence.locale}) — ${sample.latencyMs.toFixed(0)} ms`,
|
||||||
|
);
|
||||||
|
console.info(` texte : ${sample.sentence.text}`);
|
||||||
|
console.info(` attendu : ${sample.sentence.note}`);
|
||||||
|
console.table(
|
||||||
|
sample.result.actions.map((action) => ({
|
||||||
|
action: action.action,
|
||||||
|
verbe: action.verb,
|
||||||
|
ingrédients: action.ingredients.join(", "),
|
||||||
|
"durée (min)": action.durationMinutes ?? "—",
|
||||||
|
température: action.temperature ?? "—",
|
||||||
|
ustensiles: action.utensils.join(", "),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point d'entrée : charge le modèle choisi via `OLLAMA_TECH_STEP_MODEL`
|
||||||
|
* (`"qwen2.5-1.5b"` par défaut), contre le serveur Ollama de
|
||||||
|
* `OLLAMA_TECH_STEP_HOST` (`http://127.0.0.1:11434` par défaut), lance le
|
||||||
|
* benchmark sur les 11 phrases partagées, imprime les résultats détaillés
|
||||||
|
* puis le récapitulatif, et décharge le modèle avant de quitter.
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const modelKey: RecommendedOllamaModelKey =
|
||||||
|
process.env.OLLAMA_TECH_STEP_MODEL === "llama-3.2-1b" ? "llama-3.2-1b" : "qwen2.5-1.5b";
|
||||||
|
const host = process.env.OLLAMA_TECH_STEP_HOST ?? DEFAULT_OLLAMA_HOST;
|
||||||
|
console.info(
|
||||||
|
`[ollama] modèle sélectionné : ${modelKey} (${RECOMMENDED_MODELS[modelKey].rationale})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const analyzer = new OllamaStepAnalyzer(host);
|
||||||
|
try {
|
||||||
|
await analyzer.initialize(modelKey);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ollama] échec de l'initialisation", err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const warmUpStartedAt = performance.now();
|
||||||
|
try {
|
||||||
|
await analyzer.warmUp();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ollama] échec du warm-up — le benchmark continue quand même", err);
|
||||||
|
}
|
||||||
|
console.info(`[ollama] warm-up en ${(performance.now() - warmUpStartedAt).toFixed(0)} ms`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const benchmarkStartedAt = performance.now();
|
||||||
|
const samples = await runBenchmark<RecipeStepAnalysis>({
|
||||||
|
logPrefix: "[ollama]",
|
||||||
|
countOf: (result) => result.actions.length,
|
||||||
|
countLabel: "action(s) détectée(s)",
|
||||||
|
analyze: (sentence: BenchmarkSentence) => analyzer.analyzeStep(sentence.text),
|
||||||
|
});
|
||||||
|
console.info(
|
||||||
|
`[ollama] benchmark complet en ${(performance.now() - benchmarkStartedAt).toFixed(0)} ms`,
|
||||||
|
);
|
||||||
|
printDetailedResults(samples);
|
||||||
|
console.info(
|
||||||
|
"\n[ollama] rappel : la colonne 'RSS moy.' ci-dessous ne mesure rien d'utile pour ce moteur — l'inférence tourne dans le process `ollama serve`, pas dans ce script (voir le doc-comment en tête de fichier).",
|
||||||
|
);
|
||||||
|
printSummaryTable(samples, (result) => result.actions.length, "actions détectées");
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await analyzer.dispose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ollama] erreur lors de la libération du modèle", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMainModule(import.meta.url)) {
|
||||||
|
await main();
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue