Compare commits
2 commits
main
...
feat/sourc
| Author | SHA1 | Date | |
|---|---|---|---|
| ee22469144 | |||
| 8eb949fd28 |
108 changed files with 2560 additions and 11596 deletions
|
|
@ -22,14 +22,6 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# 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
|
# Only needed to run the optional `tech-step-llm-worker` service — shared
|
||||||
# between it and "app" (docker-compose.yml). Generate your own the same
|
# between it and "app" (docker-compose.yml). Generate your own the same
|
||||||
# way as JWT_SECRET above; leave both this and the service commented
|
# way as JWT_SECRET above; leave both this and the service commented
|
||||||
|
|
|
||||||
89
.github/workflows/ci.yml
vendored
89
.github/workflows/ci.yml
vendored
|
|
@ -12,32 +12,26 @@ on:
|
||||||
push:
|
push:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: "postgresql://ci:ci@postgres:5432/batchcooking_ci?schema=public"
|
DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public"
|
||||||
# Test-only secret, never used outside CI — real deployments must set their own.
|
# 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
|
# Same reasoning as JWT_SECRET above — lets tech-step-worker.routes.test.ts
|
||||||
# exercise the success path (matching secret), not just the "unset"
|
# exercise the success path (matching secret), not just the "unset"
|
||||||
# rejection every environment that doesn't set this gets by default.
|
# rejection every environment that doesn't set this gets by default.
|
||||||
INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+"
|
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 +49,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 +87,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') }}
|
||||||
|
|
@ -159,10 +107,7 @@ jobs:
|
||||||
# download (see apps/web's cypress caveat in the README) — install it
|
# download (see apps/web's cypress caveat in the README) — install it
|
||||||
# explicitly so `cypress run` finds it.
|
# explicitly so `cypress run` finds it.
|
||||||
- run: pnpm --filter web exec cypress install
|
- run: pnpm --filter web exec cypress install
|
||||||
- name: Run E2E tests
|
- run: pnpm --filter web e2e
|
||||||
env:
|
|
||||||
HOST: "0.0.0.0"
|
|
||||||
run: pnpm --filter web e2e
|
|
||||||
# No dev server needed here — Cypress spins up its own Vite dev
|
# No dev server needed here — Cypress spins up its own Vite dev
|
||||||
# server internally for component testing (see cypress.config.ts's
|
# server internally for component testing (see cypress.config.ts's
|
||||||
# `component.devServer`), unlike `e2e` above which needs the real app
|
# `component.devServer`), unlike `e2e` above which needs the real app
|
||||||
|
|
|
||||||
11
.gitignore
vendored
11
.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
|
||||||
|
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,6 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# JWT_EXPIRES_IN=7d
|
# JWT_EXPIRES_IN=7d
|
||||||
# AUTH_COOKIE_NAME=session
|
# AUTH_COOKIE_NAME=session
|
||||||
# CORS_ORIGIN=http://localhost:5173
|
# CORS_ORIGIN=http://localhost:5173
|
||||||
# INTENT_SERVICE_BASE_URL=http://localhost:8000
|
|
||||||
|
|
||||||
# Required — services/tech-step-intent-service must be running locally (see
|
|
||||||
# that service's own README) for any recipe save/preview to detect
|
|
||||||
# techniques at all. Must match that service's own INTENT_SERVICE_SECRET.
|
|
||||||
# Generate your own the same way as JWT_SECRET above.
|
|
||||||
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|
||||||
|
|
||||||
# Only needed if you're running services/tech-step-llm-worker locally —
|
# Only needed if you're running services/tech-step-llm-worker locally —
|
||||||
# every /internal/tech-steps/* request is rejected outright while unset.
|
# every /internal/tech-steps/* request is rejected outright while unset.
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,6 @@ DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking_test?sc
|
||||||
# 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
|
# Optional — only needed to exercise tech-step-worker.routes.test.ts's
|
||||||
# success path (a request with a matching secret); every other test runs
|
# success path (a request with a matching secret); every other test runs
|
||||||
# fine without it. Any value at least 32 chars works locally.
|
# fine without it. Any value at least 32 chars works locally.
|
||||||
|
|
|
||||||
|
|
@ -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,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;
|
|
||||||
|
|
@ -521,9 +521,6 @@ model Ingredient {
|
||||||
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")
|
||||||
}
|
}
|
||||||
|
|
@ -600,12 +597,6 @@ model Unit {
|
||||||
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,13 +627,13 @@ 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
|
||||||
|
|
@ -661,25 +652,6 @@ model TechStep {
|
||||||
@@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
|
||||||
|
|
@ -750,70 +722,11 @@ model StepTechStep {
|
||||||
|
|
||||||
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 —
|
/// One user-submitted correction to a `Step`'s detected techniques —
|
||||||
/// captures ADD (a missing technique the classifier didn't find),
|
/// captures ADD (a missing technique the classifier didn't find),
|
||||||
/// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape:
|
/// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape:
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ 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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -51,7 +50,6 @@ export function createServer(): ExpressServer {
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -71,25 +71,6 @@ const envSchema = z.object({
|
||||||
* fails closed rather than open if a real deployment forgets to set it.
|
* fails closed rather than open if a real deployment forgets to set it.
|
||||||
*/
|
*/
|
||||||
INTERNAL_WORKER_SECRET: z.string().min(32).optional(),
|
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 null;
|
||||||
return best?.unitId ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What {@link extractQuantity} pulls out of a leading numeric expression, alongside what's left of the string after it. */
|
/** What {@link extractQuantity} pulls out of a leading numeric expression, alongside what's left of the string after it. */
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
* Hand-labeled evaluation set for {@link techStepClassifier} — what
|
* Hand-labeled evaluation set for {@link techStepClassifier} — what
|
||||||
* `tech-step-eval.test.ts` runs the real classifier against to compute
|
* `tech-step-eval.test.ts` runs the real classifier against to compute
|
||||||
* precision/recall/F1 (`tech-step-evaluator.ts`), the objective gate any
|
* precision/recall/F1 (`tech-step-evaluator.ts`), the objective gate any
|
||||||
* future change to `services/tech-step-intent-service`'s `training_data.py`
|
* future change to `tech-step-training-data.ts` must clear (see that
|
||||||
* must clear (see that module's own doc comment).
|
* module's own doc comment).
|
||||||
*
|
*
|
||||||
* Deliberately *not* reusing `TECH_STEP_TRAINING_DATA`'s own `utterances`
|
* Deliberately *not* reusing `TECH_STEP_TRAINING_DATA`'s own `utterances`
|
||||||
* verbatim — scoring the classifier against the exact sentences it was
|
* verbatim — scoring the classifier against the exact sentences it was
|
||||||
|
|
@ -246,7 +246,7 @@ export const TECH_STEP_EVAL_DATASET: TechStepEvalCase[] = [
|
||||||
// --- Documented false-positive traps, re-verified with fresh wording ---
|
// --- Documented false-positive traps, re-verified with fresh wording ---
|
||||||
// `brown`'s EN synonyms are verb forms only ("browned"/"browning"), not
|
// `brown`'s EN synonyms are verb forms only ("browned"/"browning"), not
|
||||||
// bare "brown" — precisely so this doesn't false-positive (see that
|
// bare "brown" — precisely so this doesn't false-positive (see that
|
||||||
// entry's own comment in training_data.py).
|
// entry's own comment in tech-step-training-data.ts).
|
||||||
{
|
{
|
||||||
description: "This recipe calls for two tablespoons of brown sugar.",
|
description: "This recipe calls for two tablespoons of brown sugar.",
|
||||||
locale: "en",
|
locale: "en",
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,8 @@
|
||||||
* hand-labeled evaluation set (`tech-step-eval-dataset.ts`) — the objective
|
* hand-labeled evaluation set (`tech-step-eval-dataset.ts`) — the objective
|
||||||
* counterpart to the "inspected by eye" verdict every corpus change used to
|
* counterpart to the "inspected by eye" verdict every corpus change used to
|
||||||
* get before this module existed. Every future edit to
|
* get before this module existed. Every future edit to
|
||||||
* `services/tech-step-intent-service`'s `training_data.py` (including the
|
* `tech-step-training-data.ts` (including the LLM-assisted suggestions the
|
||||||
* LLM-assisted suggestions the worker in `services/tech-step-llm-worker`
|
* worker in `services/tech-step-llm-worker` proposes) is expected to run
|
||||||
* proposes) is expected to run
|
|
||||||
* through `tech-step-eval.test.ts`'s regression gate, which calls
|
* through `tech-step-eval.test.ts`'s regression gate, which calls
|
||||||
* {@link computeTechStepMetrics} — a corpus change that raises recall on one
|
* {@link computeTechStepMetrics} — a corpus change that raises recall on one
|
||||||
* technique but silently tanks another's precision should fail loudly here,
|
* technique but silently tanks another's precision should fail loudly here,
|
||||||
|
|
|
||||||
|
|
@ -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,31 +241,18 @@ 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;
|
export const CONFIDENCE_THRESHOLD = 0.75;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One clause's full classification detail — the finer-grained sibling of
|
* One clause's full classification detail — the finer-grained sibling of
|
||||||
|
|
@ -317,45 +273,70 @@ export interface TechStepClauseClassification {
|
||||||
end: number;
|
end: number;
|
||||||
/** The clause's NER anchor's own implied technique `uid`, if it had one — same as `TechStepClause.anchor.uid`. */
|
/** The clause's NER anchor's own implied technique `uid`, if it had one — same as `TechStepClause.anchor.uid`. */
|
||||||
anchorUid: string | null;
|
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. */
|
/** The intent classifier's own top guess for this clause, whatever its score — `null` only when it returned node-nlp's `"None"` sentinel. 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;
|
intentUid: string | null;
|
||||||
/** The intent classifier's own confidence for `intentUid` — `0` when `intentUid` is `null` (nothing to have a score about). */
|
/** The intent classifier's own confidence for `intentUid` — `0` when `intentUid` is `null` (nothing to have a score about). */
|
||||||
score: number;
|
score: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the `TechStep.key -> id` lookup behind {@link matchTechStepSpans} —
|
* Trains and owns the `node-nlp` model behind {@link matchTechStepSpans} —
|
||||||
* a real class (not a plain object of functions) per this repo's
|
* 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 +360,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 +393,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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -479,13 +428,17 @@ export class TechStepClassifierService {
|
||||||
locale: string,
|
locale: string,
|
||||||
): Promise<TechStepClauseClassification[]> {
|
): Promise<TechStepClauseClassification[]> {
|
||||||
try {
|
try {
|
||||||
await this._ensureTechStepIdsLoaded();
|
await this._ensureTrained();
|
||||||
if (description.trim().length === 0) return [];
|
if (description.trim().length === 0) return [];
|
||||||
|
|
||||||
const nerResult = await intentServiceClient.process(locale, description);
|
const nerResult = await this._manager.process(locale, description);
|
||||||
const candidates: TechniqueCandidate[] = nerResult.entities
|
const candidates: TechniqueCandidate[] = nerResult.entities
|
||||||
.filter((entity) => entity.kind === "technique")
|
.filter((entity) => entity.type === "enum")
|
||||||
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
|
.map((entity) => ({
|
||||||
|
uid: entity.entity,
|
||||||
|
start: entity.start,
|
||||||
|
end: entity.end + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
const clauses = splitIntoClauses(description, candidates);
|
const clauses = splitIntoClauses(description, candidates);
|
||||||
const results: TechStepClauseClassification[] = [];
|
const results: TechStepClauseClassification[] = [];
|
||||||
|
|
@ -503,14 +456,15 @@ export class TechStepClassifierService {
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const result = await intentServiceClient.process(locale, clauseText);
|
const result = await this._manager.process(locale, clauseText);
|
||||||
|
const intentUid = result.intent !== "None" ? result.intent : null;
|
||||||
results.push({
|
results.push({
|
||||||
clauseText,
|
clauseText,
|
||||||
start: clause.start,
|
start: clause.start,
|
||||||
end: clause.end,
|
end: clause.end,
|
||||||
anchorUid,
|
anchorUid,
|
||||||
intentUid: result.intent,
|
intentUid,
|
||||||
score: result.intent === null ? 0 : result.score,
|
score: intentUid === null ? 0 : result.score,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
|
|
@ -552,8 +506,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 +517,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
|
for (const entry of TECH_STEP_TRAINING_DATA) {
|
||||||
|
for (const [locale, data] of [
|
||||||
|
["fr", entry.fr],
|
||||||
|
["en", entry.en],
|
||||||
|
] as const) {
|
||||||
|
if (data.synonyms.length > 0) {
|
||||||
|
this._manager.addNamedEntityText(entry.uid, entry.uid, [locale], data.synonyms);
|
||||||
|
}
|
||||||
|
for (const utterance of data.utterances) {
|
||||||
|
this._manager.addDocument(locale, utterance, entry.uid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `Utensil.key -> id` counterpart of {@link _ensureTechStepIdsLoaded} — same memoize-once-retry-on-failure shape. */
|
await this._manager.train();
|
||||||
private async _ensureUtensilIdsLoaded(): Promise<void> {
|
|
||||||
if (this._utensilIdsLoaded === undefined) {
|
|
||||||
this._utensilIdsLoaded = this._loadUtensilIds();
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await this._utensilIdsLoaded;
|
|
||||||
} catch (err) {
|
|
||||||
this._utensilIdsLoaded = undefined;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _loadUtensilIds(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const utensils = await prisma.utensil.findMany({ select: { id: true, key: true } });
|
|
||||||
this._utensilIdByUid = new Map(utensils.map((utensil) => [utensil.key, utensil.id]));
|
|
||||||
} catch (err) {
|
} 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",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -84,75 +84,6 @@ async function assertTechStepsExist(ids: number[]): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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
|
* Renumbers every one of `stepId`'s `StepTechStep` rows' `order` by
|
||||||
* ascending `start` (nulls-still-possible legacy rows, see that model's
|
* ascending `start` (nulls-still-possible legacy rows, see that model's
|
||||||
|
|
@ -194,20 +125,6 @@ export async function renumberStepTechSteps(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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`
|
* Applies a correction's *effect* on `stepId`'s real `StepTechStep`
|
||||||
* sequence, immediately — not just recorded as a pending suggestion for
|
* sequence, immediately — not just recorded as a pending suggestion for
|
||||||
|
|
@ -225,28 +142,13 @@ interface ManualUtensilMention {
|
||||||
* `contextEnd` — a correction only ever carries the tight span the user
|
* `contextEnd` — a correction only ever carries the tight span the user
|
||||||
* themselves selected/clicked, nothing wider to highlight around it.
|
* themselves selected/clicked, nothing wider to highlight around it.
|
||||||
* - `previousTechStepId` alone (remove, `correctedTechStepId: null`): the
|
* - `previousTechStepId` alone (remove, `correctedTechStepId: null`): the
|
||||||
* matching existing entry is deleted outright (cascading away any
|
* matching existing entry is deleted outright. A no-op if none matches
|
||||||
* ingredient/utensil metadata attached to it, auto or manual — nothing
|
* (nothing to remove).
|
||||||
* 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
|
* Runs inside the same transaction {@link submitTechStepCorrection} uses
|
||||||
* for the audit-trail insert, so a request never leaves any of these
|
* for the audit-trail insert, so a request never leaves the two effects
|
||||||
* effects (the permanent correction record, the live sequence change, the
|
* (the permanent correction record, the live sequence change) only
|
||||||
* metadata replacement) only partially applied.
|
* partially applied.
|
||||||
*/
|
*/
|
||||||
async function applyManualCorrection(
|
async function applyManualCorrection(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
|
|
@ -254,7 +156,6 @@ async function applyManualCorrection(
|
||||||
span: { start: number; end: number },
|
span: { start: number; end: number },
|
||||||
previousTechStepId: number | null,
|
previousTechStepId: number | null,
|
||||||
correctedTechStepId: number | null,
|
correctedTechStepId: number | null,
|
||||||
metadata?: { ingredients: ManualIngredientMention[]; utensils: ManualUtensilMention[] },
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const existing = await tx.stepTechStep.findMany({ where: { stepId } });
|
const existing = await tx.stepTechStep.findMany({ where: { stepId } });
|
||||||
|
|
||||||
|
|
@ -271,12 +172,9 @@ async function applyManualCorrection(
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (correctedTechStepId !== null) {
|
if (correctedTechStepId !== null) {
|
||||||
const order = target
|
|
||||||
? target.order
|
|
||||||
: existing.reduce((max, row) => Math.max(max, row.order), -1) + 1;
|
|
||||||
if (target) {
|
if (target) {
|
||||||
await tx.stepTechStep.update({
|
await tx.stepTechStep.update({
|
||||||
where: { stepId_order: { stepId, order } },
|
where: { stepId_order: { stepId, order: target.order } },
|
||||||
data: {
|
data: {
|
||||||
techStepId: correctedTechStepId,
|
techStepId: correctedTechStepId,
|
||||||
start: span.start,
|
start: span.start,
|
||||||
|
|
@ -287,48 +185,18 @@ async function applyManualCorrection(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const nextOrder = existing.reduce((max, row) => Math.max(max, row.order), -1) + 1;
|
||||||
await tx.stepTechStep.create({
|
await tx.stepTechStep.create({
|
||||||
data: {
|
data: {
|
||||||
stepId,
|
stepId,
|
||||||
techStepId: correctedTechStepId,
|
techStepId: correctedTechStepId,
|
||||||
order,
|
order: nextOrder,
|
||||||
start: span.start,
|
start: span.start,
|
||||||
end: span.end,
|
end: span.end,
|
||||||
source: "manual",
|
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) {
|
} else if (target) {
|
||||||
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
|
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
|
||||||
}
|
}
|
||||||
|
|
@ -364,12 +232,9 @@ function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorr
|
||||||
*
|
*
|
||||||
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see
|
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` — see
|
||||||
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
|
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
|
||||||
* `start`/`end` (the correction's own span, or any of
|
* `start`/`end` fall outside the step's current `description` (it may
|
||||||
* `input.ingredients`/`input.utensils`' own spans) fall outside the
|
* have been edited since the user last saw it). `404 TECH_STEP_NOT_FOUND`
|
||||||
* step's current `description` (it may have been edited since the user
|
* if either tech-step id doesn't exist.
|
||||||
* 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(
|
export async function submitTechStepCorrection(
|
||||||
recipeId: number,
|
recipeId: number,
|
||||||
|
|
@ -381,32 +246,18 @@ export async function submitTechStepCorrection(
|
||||||
try {
|
try {
|
||||||
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
|
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
|
||||||
|
|
||||||
const spans = [
|
if (input.start >= step.descriptionLength || input.end > step.descriptionLength) {
|
||||||
{ 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(
|
throw new HttpError(
|
||||||
400,
|
400,
|
||||||
ErrorCode.INVALID_CORRECTION_SPAN,
|
ErrorCode.INVALID_CORRECTION_SPAN,
|
||||||
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
|
`Span [${input.start}, ${input.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const techStepIds = [input.previousTechStepId, input.correctedTechStepId].filter(
|
const techStepIds = [input.previousTechStepId, input.correctedTechStepId].filter(
|
||||||
(id): id is number => id !== null && id !== undefined,
|
(id): id is number => id !== null && id !== undefined,
|
||||||
);
|
);
|
||||||
await assertTechStepsExist(techStepIds);
|
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 { correction, techSteps } = await prisma.$transaction(async (tx) => {
|
||||||
const createdCorrection = await tx.stepTechStepCorrection.create({
|
const createdCorrection = await tx.stepTechStepCorrection.create({
|
||||||
|
|
@ -427,46 +278,12 @@ export async function submitTechStepCorrection(
|
||||||
{ start: input.start, end: input.end },
|
{ start: input.start, end: input.end },
|
||||||
input.previousTechStepId ?? null,
|
input.previousTechStepId ?? null,
|
||||||
input.correctedTechStepId ?? 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({
|
const freshTechSteps = await tx.stepTechStep.findMany({
|
||||||
where: { stepId: step.id },
|
where: { stepId: step.id },
|
||||||
orderBy: { order: "asc" },
|
orderBy: { order: "asc" },
|
||||||
include: {
|
include: { techStep: true },
|
||||||
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: createdCorrection, techSteps: freshTechSteps };
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -167,8 +143,7 @@ export function toStepTechStepViews(
|
||||||
): 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, source } = 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 },
|
||||||
|
|
@ -182,22 +157,6 @@ export function toStepTechStepViews(
|
||||||
// `StepTechStepView.source` to the frontend.
|
// `StepTechStepView.source` to the frontend.
|
||||||
source: source === "manual" ? "manual" : "auto",
|
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 +551,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
|
||||||
|
|
@ -236,30 +231,6 @@ export async function previewSourceItem(
|
||||||
// comment) — always the classifier's own live match,
|
// comment) — always the classifier's own live match,
|
||||||
// never a correction, so always "auto".
|
// never a correction, so always "auto".
|
||||||
source: "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 }]
|
|
||||||
: [];
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import { renumberStepTechSteps } from "../modules/recipe/recipe-tech-step-correc
|
||||||
/**
|
/**
|
||||||
* Recomputes every existing `Step`'s `"auto"`-sourced `StepTechStep`
|
* Recomputes every existing `Step`'s `"auto"`-sourced `StepTechStep`
|
||||||
* entries against the *current* classifier
|
* entries 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, so this matches real resave behavior exactly rather than
|
* exists, so this matches real resave behavior exactly rather than
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
|
|
@ -6,15 +6,14 @@ import { prisma } from "../db/prisma.js";
|
||||||
* comment) — generated by `services/tech-step-llm-worker`'s scheduled
|
* comment) — generated by `services/tech-step-llm-worker`'s scheduled
|
||||||
* jobs, from either a user correction or the worker's own low-confidence
|
* jobs, from either a user correction or the worker's own low-confidence
|
||||||
* audit (`sourceType`). What a maintainer reads *before* hand-editing
|
* audit (`sourceType`). What a maintainer reads *before* hand-editing
|
||||||
* `services/tech-step-intent-service/intent_service/training_data.py` and
|
* `tech-step-training-data.ts` and running `retrain-tech-steps.ts` — this
|
||||||
* running `retrain-tech-steps.ts` — this script never writes anything,
|
* script never writes anything, purely a read-only report to stdout:
|
||||||
* purely a read-only report to stdout:
|
|
||||||
*
|
*
|
||||||
* pnpm --filter api exec tsx src/scripts/list-pending-training-suggestions.ts
|
* pnpm --filter api exec tsx src/scripts/list-pending-training-suggestions.ts
|
||||||
*
|
*
|
||||||
* Grouped by technique key so every suggestion for the same entry in
|
* Grouped by technique key so every suggestion for the same entry in
|
||||||
* `training_data.py`'s `TECH_STEP_TRAINING_DATA` is read together, matching
|
* `TECH_STEP_TRAINING_DATA` is read together, matching how that file
|
||||||
* how that file itself is organized (one block per technique).
|
* itself is organized (one block per technique).
|
||||||
*/
|
*/
|
||||||
async function listPendingTrainingSuggestions(): Promise<void> {
|
async function listPendingTrainingSuggestions(): Promise<void> {
|
||||||
const suggestions = await prisma.techStepTrainingSuggestion.findMany({
|
const suggestions = await prisma.techStepTrainingSuggestion.findMany({
|
||||||
|
|
|
||||||
|
|
@ -28,15 +28,10 @@ function parseSuggestionIds(flag: "applied" | "rejected"): number[] {
|
||||||
* Maintainer workflow closing the loop on a training-corpus change (see
|
* Maintainer workflow closing the loop on a training-corpus change (see
|
||||||
* this feature's plan document):
|
* this feature's plan document):
|
||||||
*
|
*
|
||||||
* 1. A maintainer has already hand-edited
|
* 1. A maintainer has already hand-edited `tech-step-training-data.ts`
|
||||||
* `services/tech-step-intent-service/intent_service/training_data.py`
|
* (informed by `list-pending-training-suggestions.ts`'s report), and
|
||||||
* (informed by `list-pending-training-suggestions.ts`'s report),
|
|
||||||
* decided which `TechStepTrainingSuggestion` ids they incorporated
|
* decided which `TechStepTrainingSuggestion` ids they incorporated
|
||||||
* (`--applied=`) or explicitly discarded (`--rejected=`), **and
|
* (`--applied=`) or explicitly discarded (`--rejected=`).
|
||||||
* 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
|
* 2. This script re-runs the F1 regression gate
|
||||||
* ({@link runTechStepEvalSuite} against {@link MIN_OVERALL_F1}) —
|
* ({@link runTechStepEvalSuite} against {@link MIN_OVERALL_F1}) —
|
||||||
* refuses to backfill at all if the edited corpus scores worse than
|
* refuses to backfill at all if the edited corpus scores worse than
|
||||||
|
|
|
||||||
|
|
@ -9,44 +9,21 @@ 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.
|
|
||||||
*
|
|
||||||
* Retried with exponential backoff: in Docker Compose, `app`'s own
|
|
||||||
* `depends_on: tech-step-intent-service: condition: service_healthy`
|
|
||||||
* (`docker-compose.yml`) already means that service is up by the time this
|
|
||||||
* runs, but native dev (`pnpm dev:api`, no Compose ordering at all) can
|
|
||||||
* easily start this before the intent service has finished loading its
|
|
||||||
* spaCy models — a transient connection failure here shouldn't need a
|
|
||||||
* manual restart. Still non-fatal after every attempt is exhausted: the
|
|
||||||
* *next* real call retries training itself (see `_ensureTrained`'s own
|
|
||||||
* retry-on-failure comment), same graceful-degrade posture as before this
|
|
||||||
* retry loop existed.
|
|
||||||
*/
|
|
||||||
async function warmUpTechStepClassifier(): Promise<void> {
|
|
||||||
const maxAttempts = 5;
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
||||||
try {
|
try {
|
||||||
await techStepClassifier.warmUp();
|
await techStepClassifier.warmUp();
|
||||||
return;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (attempt === maxAttempts) {
|
// Not fatal to startup — a failed warm-up just means the *next* call
|
||||||
logger.error("Tech-step classifier warm-up failed after retries", {
|
// retries training itself (see `_ensureTrained`'s own retry-on-failure
|
||||||
|
// comment), same graceful-degrade posture as everywhere else training
|
||||||
|
// failures surface. Still worth a loud log: this shouldn't normally fail.
|
||||||
|
logger.error("Tech-step classifier warm-up failed", {
|
||||||
error: err instanceof Error ? err.message : String(err),
|
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();
|
||||||
|
|
||||||
|
|
|
||||||
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,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,
|
||||||
|
|
|
||||||
|
|
@ -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,19 +132,10 @@ 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" } }),
|
||||||
|
|
@ -155,15 +143,6 @@ describe("tech-step-matcher", () => {
|
||||||
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;
|
||||||
|
|
@ -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.
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,24 +25,6 @@ async function techStepId(key: string): Promise<number> {
|
||||||
return techStep.id;
|
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", () => {
|
describe("Recipe tech-step corrections", () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
|
|
@ -97,7 +79,7 @@ describe("Recipe tech-step corrections", () => {
|
||||||
const { agent, profileId } = await signup();
|
const { agent, profileId } = await signup();
|
||||||
// "Faire mijoter la sauce." names no technique the classifier itself
|
// "Faire mijoter la sauce." names no technique the classifier itself
|
||||||
// registers a bare-word anchor for at this exact span in isolation
|
// 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,
|
// (see tech-step-training-data.ts) — irrelevant here either way,
|
||||||
// since this test's whole point is the *manual* addition, not
|
// since this test's whole point is the *manual* addition, not
|
||||||
// whatever the classifier does or doesn't auto-detect for it.
|
// whatever the classifier does or doesn't auto-detect for it.
|
||||||
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
|
||||||
|
|
@ -116,14 +98,7 @@ describe("Recipe tech-step corrections", () => {
|
||||||
// away — not just the permanent audit record above (see
|
// away — not just the permanent audit record above (see
|
||||||
// `applyManualCorrection`, `recipe-tech-step-correction.service.ts`).
|
// `applyManualCorrection`, `recipe-tech-step-correction.service.ts`).
|
||||||
expect(res.body.techSteps).to.deep.equal([
|
expect(res.body.techSteps).to.deep.equal([
|
||||||
{
|
{ techStep: { id: simmerId, key: "simmer" }, start: 6, end: 13, source: "manual" },
|
||||||
techStep: { id: simmerId, key: "simmer" },
|
|
||||||
start: 6,
|
|
||||||
end: 13,
|
|
||||||
source: "manual",
|
|
||||||
ingredients: [],
|
|
||||||
utensils: [],
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -149,14 +124,7 @@ describe("Recipe tech-step corrections", () => {
|
||||||
// Still exactly one entry — the relabel updated the existing row
|
// Still exactly one entry — the relabel updated the existing row
|
||||||
// rather than adding a second one alongside it.
|
// rather than adding a second one alongside it.
|
||||||
expect(res.body.techSteps).to.deep.equal([
|
expect(res.body.techSteps).to.deep.equal([
|
||||||
{
|
{ techStep: { id: boilId, key: "boil" }, start: 6, end: 13, source: "manual" },
|
||||||
techStep: { id: boilId, key: "boil" },
|
|
||||||
start: 6,
|
|
||||||
end: 13,
|
|
||||||
source: "manual",
|
|
||||||
ingredients: [],
|
|
||||||
utensils: [],
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -277,244 +245,6 @@ describe("Recipe tech-step corrections", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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", () => {
|
describe("GET /recipes/:id/steps/:stepId/corrections", () => {
|
||||||
it("returns every correction submitted for the step, most recent first", async () => {
|
it("returns every correction submitted for the step, most recent first", async () => {
|
||||||
const { agent, profileId } = await signup();
|
const { agent, profileId } = await signup();
|
||||||
|
|
|
||||||
|
|
@ -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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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,4 +1,3 @@
|
||||||
import { useState } from "react";
|
|
||||||
import "../../src/i18n/i18n";
|
import "../../src/i18n/i18n";
|
||||||
import { TechStepCorrectionPopover } from "../../src/features/recipes/steps/TechStepCorrectionPopover";
|
import { TechStepCorrectionPopover } from "../../src/features/recipes/steps/TechStepCorrectionPopover";
|
||||||
|
|
||||||
|
|
@ -12,40 +11,15 @@ import { TechStepCorrectionPopover } from "../../src/features/recipes/steps/Tech
|
||||||
|
|
||||||
const cook = { id: 1, key: "cook" };
|
const cook = { id: 1, key: "cook" };
|
||||||
const simmer = { id: 3, key: "simmer" };
|
const simmer = { id: 3, key: "simmer" };
|
||||||
const butter = { id: 10, key: "butter" };
|
|
||||||
const pan = { id: 20, key: "pan" };
|
|
||||||
const gram = { id: 30, key: "gram" };
|
|
||||||
|
|
||||||
/**
|
function mountPopover(
|
||||||
* A real `StepDescription` resolves `onRequestSpan` into a fresh
|
overrides: Partial<{
|
||||||
* `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;
|
previousTechStepId: number | null;
|
||||||
existingIngredients: unknown[];
|
|
||||||
existingUtensils: unknown[];
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmitted: (result: unknown) => void;
|
onSubmitted: (correction: unknown) => void;
|
||||||
}>) {
|
}> = {},
|
||||||
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
|
) {
|
||||||
nonce: number;
|
cy.mount(
|
||||||
kind: "ingredient" | "utensil";
|
|
||||||
range: { start: number; end: number };
|
|
||||||
text: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
<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. */}
|
{/* 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 }} />
|
<div data-testid="outside-popover" style={{ height: 20 }} />
|
||||||
|
|
@ -54,25 +28,11 @@ function Harness({
|
||||||
stepId={2}
|
stepId={2}
|
||||||
selectedText="Cuire"
|
selectedText="Cuire"
|
||||||
range={{ start: 0, end: 5 }}
|
range={{ start: 0, end: 5 }}
|
||||||
previousTechStepId={previousTechStepId}
|
previousTechStepId={overrides.previousTechStepId ?? null}
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView props — precise typing isn't the point here.
|
onClose={overrides.onClose ?? (() => {})}
|
||||||
existingIngredients={existingIngredients as any}
|
onSubmitted={overrides.onSubmitted ?? (() => {})}
|
||||||
// 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>
|
</div>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,75 +41,32 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
cy.intercept("GET", "**/reference/tech-steps", { statusCode: 200, body: [cook, simmer] }).as(
|
cy.intercept("GET", "**/reference/tech-steps", { statusCode: 200, body: [cook, simmer] }).as(
|
||||||
"getTechSteps",
|
"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", () => {
|
it("shows the selected text and every technique option once loaded", () => {
|
||||||
cy.mount(<Harness />);
|
mountPopover();
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
|
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
|
||||||
// Merged editor (see TechStepCorrectionPopover's own doc comment) — no
|
cy.get(".tech-step-correction-popover__list button").should("have.length", 2);
|
||||||
// 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", () => {
|
it("offers a 'no technique here' option only when correcting an existing match", () => {
|
||||||
cy.mount(<Harness previousTechStepId={null} />);
|
mountPopover({ previousTechStepId: null });
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
cy.get(".tech-step-correction-popover__remove").should("not.exist");
|
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} />);
|
mountPopover({ previousTechStepId: cook.id });
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
cy.get(".tech-step-correction-popover__remove").should("exist");
|
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", () => {
|
it("submits the selected technique and calls onSubmitted", () => {
|
||||||
cy.mount(<Harness />);
|
// Asserting on the resolved `@submitCorrection` interception below,
|
||||||
cy.wait("@getTechSteps");
|
// rather than inside this handler — a Chai assertion failing *inside*
|
||||||
|
// a `cy.intercept` callback surfaces as an opaque "onResponse cannot be
|
||||||
cy.contains(
|
// called twice" Cypress internal error instead of a normal assertion
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
// failure, found while writing this exact test.
|
||||||
"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", {
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
||||||
statusCode: 201,
|
statusCode: 201,
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -162,14 +79,10 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
},
|
},
|
||||||
}).as("submitCorrection");
|
}).as("submitCorrection");
|
||||||
const onSubmitted = cy.stub().as("onSubmitted");
|
const onSubmitted = cy.stub().as("onSubmitted");
|
||||||
cy.mount(<Harness onSubmitted={onSubmitted} />);
|
mountPopover({ onSubmitted });
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
cy.contains(
|
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
|
||||||
".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", {
|
cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
|
||||||
start: 0,
|
start: 0,
|
||||||
|
|
@ -180,85 +93,16 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
cy.get("@onSubmitted").should("have.been.calledOnce");
|
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", () => {
|
it("shows an error message and stays open when the submission fails", () => {
|
||||||
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
body: { code: 4051, message: "TechStep not found" },
|
body: { code: 4051, message: "TechStep not found" },
|
||||||
}).as("submitCorrection");
|
}).as("submitCorrection");
|
||||||
const onClose = cy.stub().as("onClose");
|
const onClose = cy.stub().as("onClose");
|
||||||
cy.mount(<Harness onClose={onClose} />);
|
mountPopover({ onClose });
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
cy.contains(
|
cy.contains(".tech-step-correction-popover__list button", "Cuire").click();
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
||||||
"Cuire",
|
|
||||||
).click();
|
|
||||||
cy.contains("button", "Valider").click();
|
|
||||||
|
|
||||||
cy.wait("@submitCorrection");
|
cy.wait("@submitCorrection");
|
||||||
cy.get(".field-error").should("be.visible");
|
cy.get(".field-error").should("be.visible");
|
||||||
|
|
@ -267,7 +111,7 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
|
|
||||||
it("calls onClose on an outside click", () => {
|
it("calls onClose on an outside click", () => {
|
||||||
const onClose = cy.stub().as("onClose");
|
const onClose = cy.stub().as("onClose");
|
||||||
cy.mount(<Harness onClose={onClose} />);
|
mountPopover({ onClose });
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
cy.get('[data-testid="outside-popover"]').click();
|
cy.get('[data-testid="outside-popover"]').click();
|
||||||
|
|
|
||||||
|
|
@ -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\?/, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { startDate: "2026-08-17", finishDate: "2026-08-23", items: [] },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("centers the Liste de courses stub, with equal space on both sides", () => {
|
||||||
cy.visit("/liste-de-courses");
|
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();
|
||||||
|
|
|
||||||
|
|
@ -88,16 +88,7 @@ Given('correcting step 2\'s "Cuire" match will succeed', () => {
|
||||||
correctedTechStep: { id: 3, key: "simmer" },
|
correctedTechStep: { id: 3, key: "simmer" },
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
techSteps: [
|
techSteps: [{ techStep: { id: 3, key: "simmer" }, start: 0, end: 5, source: "manual" }],
|
||||||
{
|
|
||||||
techStep: { id: 3, key: "simmer" },
|
|
||||||
start: 0,
|
|
||||||
end: 5,
|
|
||||||
source: "manual",
|
|
||||||
ingredients: [],
|
|
||||||
utensils: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}).as("correction");
|
}).as("correction");
|
||||||
});
|
});
|
||||||
|
|
@ -110,21 +101,8 @@ Then("I should see the technique correction options", () => {
|
||||||
cy.get(".tech-step-correction-popover").should("be.visible");
|
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) => {
|
When("I choose {string} as the correct technique", (label: string) => {
|
||||||
cy.contains(
|
cy.contains(".tech-step-correction-popover__list button", label).click();
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
|
||||||
label,
|
|
||||||
).click();
|
|
||||||
cy.contains(".tech-step-correction-popover__confirm-button", "Valider").click();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Then(
|
Then(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "echo \"no unit tests yet\" && exit 0",
|
"test": "echo \"no unit tests yet\" && exit 0",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ 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 StepTechStepCorrectionView,
|
||||||
|
|
@ -27,7 +26,6 @@ import {
|
||||||
type ThemePreference,
|
type ThemePreference,
|
||||||
type UnitView,
|
type UnitView,
|
||||||
type UpdateRecipeInput,
|
type UpdateRecipeInput,
|
||||||
type UtensilView,
|
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -165,18 +163,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");
|
||||||
|
|
@ -202,11 +188,6 @@ export class ApiClient {
|
||||||
return this._request("/reference/tech-steps");
|
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");
|
||||||
|
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -729,41 +729,43 @@
|
||||||
margin: 0 0 var(--space-sm);
|
margin: 0 0 var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Technique picker + Ingrédients/Ustensiles render together as one
|
&__list {
|
||||||
// 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;
|
display: flex;
|
||||||
align-items: baseline;
|
flex-wrap: wrap;
|
||||||
justify-content: space-between;
|
gap: var(--space-xs);
|
||||||
gap: var(--space-sm);
|
list-style: none;
|
||||||
}
|
margin: 0 0 var(--space-sm);
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
&__remove {
|
button {
|
||||||
padding: 0.2rem 0.5rem;
|
padding: 0.3rem 0.6rem;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-error);
|
color: var(--color-text);
|
||||||
background: none;
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-error);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nested (rather than a sibling `&__remove` block) so its border-color
|
||||||
|
// wins over the plain `button` rule above by class-count specificity,
|
||||||
|
// no `!important` needed.
|
||||||
|
.tech-step-correction-popover__remove {
|
||||||
|
color: var(--color-error);
|
||||||
|
border-color: var(--color-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__cancel {
|
&__cancel {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -773,180 +775,6 @@
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: var(--font-size-sm);
|
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) -----------------------------
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -76,45 +76,10 @@ export function StepDescription({
|
||||||
previousTechStepId: number | null;
|
previousTechStepId: number | null;
|
||||||
} | null>(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() {
|
function handleMouseUp() {
|
||||||
if (!editable) return;
|
if (!editable) return;
|
||||||
const range = getSelectionRange();
|
const range = getSelectionRange();
|
||||||
if (!range) return;
|
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({
|
setActiveCorrection({
|
||||||
range,
|
range,
|
||||||
selectedText: description.slice(range.start, range.end),
|
selectedText: description.slice(range.start, range.end),
|
||||||
|
|
@ -126,21 +91,6 @@ export function StepDescription({
|
||||||
setLiveTechSteps(result.techSteps);
|
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
|
// Tracks each segment's own absolute start offset into `description` as
|
||||||
// the map below walks them in order — segments are contiguous and cover
|
// the map below walks them in order — segments are contiguous and cover
|
||||||
// the whole description (see `splitDescriptionByTechSteps`'s doc
|
// the whole description (see `splitDescriptionByTechSteps`'s doc
|
||||||
|
|
@ -204,19 +154,12 @@ export function StepDescription({
|
||||||
data-offset={editable ? start : undefined}
|
data-offset={editable ? start : undefined}
|
||||||
onClick={
|
onClick={
|
||||||
editable
|
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({
|
setActiveCorrection({
|
||||||
range: { start, end },
|
range: { start, end },
|
||||||
selectedText: segment.text,
|
selectedText: segment.text,
|
||||||
previousTechStepId: techStep.id,
|
previousTechStepId: techStep.id,
|
||||||
});
|
})
|
||||||
}
|
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -233,11 +176,7 @@ export function StepDescription({
|
||||||
range={activeCorrection.range}
|
range={activeCorrection.range}
|
||||||
selectedText={activeCorrection.selectedText}
|
selectedText={activeCorrection.selectedText}
|
||||||
previousTechStepId={activeCorrection.previousTechStepId}
|
previousTechStepId={activeCorrection.previousTechStepId}
|
||||||
existingIngredients={activeStepTechStep?.ingredients ?? []}
|
onClose={() => setActiveCorrection(null)}
|
||||||
existingUtensils={activeStepTechStep?.utensils ?? []}
|
|
||||||
resolvedMetadataSpan={resolvedMetadataSpan}
|
|
||||||
onRequestSpan={setPendingSpanRequest}
|
|
||||||
onClose={closeActiveCorrection}
|
|
||||||
onSubmitted={handleSubmitted}
|
onSubmitted={handleSubmitted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,14 @@
|
||||||
import {
|
import {
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type IngredientView,
|
|
||||||
type StepTechStepIngredientView,
|
|
||||||
type StepTechStepUtensilView,
|
|
||||||
type SubmitTechStepCorrectionResult,
|
type SubmitTechStepCorrectionResult,
|
||||||
type TechStepView,
|
type TechStepView,
|
||||||
type UnitView,
|
|
||||||
type UtensilView,
|
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ApiError, apiClient } from "../../../api/client";
|
import { ApiError, apiClient } from "../../../api/client";
|
||||||
import { errorMessageService } from "../../../services/error-message.service";
|
import { errorMessageService } from "../../../services/error-message.service";
|
||||||
import { CatalogSearchPicker } from "./CatalogSearchPicker";
|
|
||||||
import type { TextSelectionRange } from "./use-text-selection";
|
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
|
* 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 —
|
* span of a step's description, or clear/relabel an existing match —
|
||||||
|
|
@ -55,29 +21,14 @@ function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
|
||||||
* `StepDescription.tsx`), not floating anchored at the selection's exact
|
* `StepDescription.tsx`), not floating anchored at the selection's exact
|
||||||
* position — simpler and more robust than tracking a caret-anchored
|
* position — simpler and more robust than tracking a caret-anchored
|
||||||
* position across scroll/resize, at the cost of a little visual distance
|
* position across scroll/resize, at the cost of a little visual distance
|
||||||
* from the selected text itself. That placement matters beyond cosmetics
|
* from the selected text itself.
|
||||||
* 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
|
* Submitting takes effect immediately — the API applies it to the step's
|
||||||
* (`CatalogSearchPicker`, searchable — the reference catalog is ~74
|
* real `StepTechStep` sequence as it records the correction (a `"manual"`-
|
||||||
* entries, an unfiltered flat list wasn't browsable) and editing its
|
* tagged entry, see `StepTechStepCorrection`'s schema doc comment) and
|
||||||
* Ingrédients/Ustensiles metadata render together on the same screen,
|
* returns the fresh sequence, which `onSubmitted` hands back to
|
||||||
* always — there's no separate "pick, then a metadata step reveals
|
* `StepDescription` to render right away, styled differently from an
|
||||||
* itself" sequence to go through, and no dead end where metadata is
|
* `"auto"` match.
|
||||||
* 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({
|
export function TechStepCorrectionPopover({
|
||||||
recipeId,
|
recipeId,
|
||||||
|
|
@ -85,10 +36,6 @@ export function TechStepCorrectionPopover({
|
||||||
selectedText,
|
selectedText,
|
||||||
range,
|
range,
|
||||||
previousTechStepId,
|
previousTechStepId,
|
||||||
existingIngredients,
|
|
||||||
existingUtensils,
|
|
||||||
resolvedMetadataSpan,
|
|
||||||
onRequestSpan,
|
|
||||||
onClose,
|
onClose,
|
||||||
onSubmitted,
|
onSubmitted,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -99,74 +46,12 @@ export function TechStepCorrectionPopover({
|
||||||
range: TextSelectionRange;
|
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. */
|
/** 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;
|
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;
|
onClose: () => void;
|
||||||
onSubmitted: (result: SubmitTechStepCorrectionResult) => void;
|
onSubmitted: (result: SubmitTechStepCorrectionResult) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(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 [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -185,46 +70,6 @@ export function TechStepCorrectionPopover({
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 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(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(e: MouseEvent) {
|
function handleClickOutside(e: MouseEvent) {
|
||||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||||
|
|
@ -235,7 +80,7 @@ export function TechStepCorrectionPopover({
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
async function removeMatch() {
|
async function submit(correctedTechStepId: number | null) {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -243,7 +88,7 @@ export function TechStepCorrectionPopover({
|
||||||
start: range.start,
|
start: range.start,
|
||||||
end: range.end,
|
end: range.end,
|
||||||
previousTechStepId,
|
previousTechStepId,
|
||||||
correctedTechStepId: null,
|
correctedTechStepId,
|
||||||
});
|
});
|
||||||
onSubmitted(result);
|
onSubmitted(result);
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -254,260 +99,40 @@ export function TechStepCorrectionPopover({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="tech-step-correction-popover" ref={popoverRef}>
|
<div className="tech-step-correction-popover" ref={popoverRef}>
|
||||||
<p className="tech-step-correction-popover__selection">
|
<p className="tech-step-correction-popover__selection">
|
||||||
{t("recipes.techStepCorrection.selectionLabel", { text: selectedText })}
|
{t("recipes.techStepCorrection.selectionLabel", { text: selectedText })}
|
||||||
</p>
|
</p>
|
||||||
|
{techSteps === null ? (
|
||||||
{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>
|
<p>{t("recipes.loading")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="tech-step-correction-popover__confirm">
|
<ul className="tech-step-correction-popover__list">
|
||||||
<section className="tech-step-correction-popover__technique-section">
|
|
||||||
<div className="tech-step-correction-popover__technique-header">
|
|
||||||
<h4>{t("recipes.techStepCorrection.techniqueSection")}</h4>
|
|
||||||
{previousTechStepId !== null && (
|
{previousTechStepId !== null && (
|
||||||
|
<li>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={removeMatch}
|
onClick={() => submit(null)}
|
||||||
className="tech-step-correction-popover__remove"
|
className="tech-step-correction-popover__remove"
|
||||||
>
|
>
|
||||||
{t("recipes.techStepCorrection.removeMatch")}
|
{t("recipes.techStepCorrection.removeMatch")}
|
||||||
</button>
|
</button>
|
||||||
|
</li>
|
||||||
)}
|
)}
|
||||||
</div>
|
{techSteps.map((techStep) => (
|
||||||
<p className="tech-step-correction-popover__chosen-technique">
|
<li key={techStep.id}>
|
||||||
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeIngredient(index)}
|
disabled={isSubmitting || techStep.id === previousTechStepId}
|
||||||
title={t("recipes.techStepCorrection.removeIngredient")}
|
onClick={() => submit(techStep.id)}
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
>
|
||||||
✕
|
{t(`catalog.techSteps.${techStep.key}`)}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</ul>
|
</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>}
|
{error && <p className="field-error">{error}</p>}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
@ -44,7 +25,6 @@
|
||||||
"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",
|
"STEP_NOT_FOUND": "Cette étape n'existe pas",
|
||||||
"TECH_STEP_NOT_FOUND": "Cette technique 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",
|
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -122,6 +102,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",
|
||||||
|
|
@ -169,24 +168,7 @@
|
||||||
"removeMatch": "Aucune technique ici",
|
"removeMatch": "Aucune technique ici",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"manualTooltip": "{{technique}} (correction manuelle)",
|
"manualTooltip": "{{technique}} (correction manuelle)",
|
||||||
"discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger.",
|
"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",
|
||||||
|
|
@ -307,8 +289,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 +424,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
|
||||||
|
|
@ -48,63 +51,8 @@ services:
|
||||||
# default: `/internal/tech-steps/*` fails closed rather than open
|
# default: `/internal/tech-steps/*` fails closed rather than open
|
||||||
# for a deployment that doesn't run the worker at all.
|
# for a deployment that doesn't run the worker at all.
|
||||||
INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-}
|
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
|
# Deliberately its own image, not built into `app`'s (see
|
||||||
# services/tech-step-llm-worker/Dockerfile's own doc comment) — a
|
# services/tech-step-llm-worker/Dockerfile's own doc comment) — a
|
||||||
|
|
|
||||||
|
|
@ -1,661 +0,0 @@
|
||||||
/**
|
|
||||||
* French display/matching labels for the `Ingredient` reference catalog
|
|
||||||
* (`apps/api/src/db/reference-seed-data.ts`'s `INGREDIENT_GROUPS`), keyed
|
|
||||||
* by the same `Ingredient.key` used throughout the app — the French
|
|
||||||
* counterpart to {@link INGREDIENT_LABELS_EN} (catalog-labels-en.ts), used
|
|
||||||
* by `apps/api/src/lib/recipe-matching/ingredient-matcher.ts` to resolve
|
|
||||||
* free-text ingredient lines from French-language recipe sources (Marmiton,
|
|
||||||
* 750g, Manger Bouger) against our catalog.
|
|
||||||
*
|
|
||||||
* Deliberately **not** hand-authored from scratch: every value here is
|
|
||||||
* copied verbatim from `apps/web/src/locales/fr/translation.json`'s
|
|
||||||
* `catalog.ingredients` (the same French names already shown in the UI),
|
|
||||||
* not written fresh for matching purposes the way {@link INGREDIENT_LABELS_EN}
|
|
||||||
* was. That reuse is a deliberate trade-off, not an oversight: it guarantees
|
|
||||||
* every one of this catalog's ~550 ingredients gets *some* French matching
|
|
||||||
* label for free, at the cost of a few labels being phrased for display
|
|
||||||
* (a UI-friendly short name) rather than for how someone would actually
|
|
||||||
* write it in running recipe text — e.g. `vanillaBean`'s "Vanille (gousse)"
|
|
||||||
* won't match "1 gousse de vanille" (matching is ordered — see
|
|
||||||
* `matchIngredientName`'s own doc comment — and "gousse" comes first in
|
|
||||||
* real usage, not "vanille"), which is exactly what
|
|
||||||
* {@link INGREDIENT_LABEL_SYNONYMS_FR} below exists to patch up, entry by
|
|
||||||
* entry, as real mismatches like that one turn up — the ingredient still
|
|
||||||
* resolves correctly once a synonym in the natural word order is added, no
|
|
||||||
* change to the primary (display) label required.
|
|
||||||
*/
|
|
||||||
export const INGREDIENT_LABELS_FR: Record<string, string> = {
|
|
||||||
// Vegetables
|
|
||||||
tomato: "Tomate",
|
|
||||||
onion: "Oignon",
|
|
||||||
shallot: "Échalote",
|
|
||||||
garlic: "Ail",
|
|
||||||
carrot: "Carotte",
|
|
||||||
zucchini: "Courgette",
|
|
||||||
cucumber: "Concombre",
|
|
||||||
gherkins: "Cornichons",
|
|
||||||
bellPepper: "Poivron",
|
|
||||||
mushroom: "Champignon",
|
|
||||||
porcini: "Cèpes",
|
|
||||||
eggplant: "Aubergine",
|
|
||||||
broccoli: "Brocoli",
|
|
||||||
cauliflower: "Chou-fleur",
|
|
||||||
whiteCabbage: "Chou blanc",
|
|
||||||
redCabbage: "Chou rouge",
|
|
||||||
brusselsSprouts: "Chou de Bruxelles",
|
|
||||||
spinach: "Épinard",
|
|
||||||
swissChard: "Blette",
|
|
||||||
lettuce: "Salade",
|
|
||||||
arugula: "Roquette",
|
|
||||||
watercress: "Cresson",
|
|
||||||
leek: "Poireau",
|
|
||||||
celery: "Céleri",
|
|
||||||
radish: "Radis",
|
|
||||||
beetroot: "Betterave",
|
|
||||||
turnip: "Navet",
|
|
||||||
parsnip: "Panais",
|
|
||||||
greenBean: "Haricot vert",
|
|
||||||
pea: "Petit pois",
|
|
||||||
corn: "Maïs",
|
|
||||||
artichoke: "Artichaut",
|
|
||||||
fennel: "Fenouil",
|
|
||||||
endive: "Endive",
|
|
||||||
pumpkin: "Potiron",
|
|
||||||
butternutSquash: "Butternut",
|
|
||||||
asparagus: "Asperge",
|
|
||||||
avocado: "Avocat",
|
|
||||||
potato: "Pomme de terre",
|
|
||||||
sweetPotato: "Patate douce",
|
|
||||||
cherryTomato: "Tomates cerises",
|
|
||||||
bokChoy: "Pak-choï",
|
|
||||||
soybeanSprouts: "Germes de soja",
|
|
||||||
shiitake: "Shiitake",
|
|
||||||
daikon: "Daikon",
|
|
||||||
freshGreenChili: "Piment vert frais",
|
|
||||||
cardoon: "Cardon",
|
|
||||||
radicchio: "Chicorée rouge",
|
|
||||||
romanesco: "Chou romanesco",
|
|
||||||
kohlrabi: "Chou-rave",
|
|
||||||
napaCabbage: "Chou chinois",
|
|
||||||
celeriac: "Céleri-rave",
|
|
||||||
okra: "Gombo",
|
|
||||||
springOnion: "Oignon nouveau",
|
|
||||||
redKuriSquash: "Potimarron",
|
|
||||||
rutabaga: "Rutabaga",
|
|
||||||
samphire: "Salicorne",
|
|
||||||
salsify: "Salsifis",
|
|
||||||
lambsLettuce: "Mâche",
|
|
||||||
escarole: "Scarole",
|
|
||||||
// Fruits
|
|
||||||
lemon: "Citron",
|
|
||||||
lime: "Citron vert",
|
|
||||||
apple: "Pomme",
|
|
||||||
pear: "Poire",
|
|
||||||
banana: "Banane",
|
|
||||||
orange: "Orange",
|
|
||||||
clementine: "Clémentine",
|
|
||||||
grapefruit: "Pamplemousse",
|
|
||||||
strawberry: "Fraise",
|
|
||||||
raspberry: "Framboise",
|
|
||||||
blueberry: "Myrtille",
|
|
||||||
blackberry: "Mûre",
|
|
||||||
cherry: "Cerise",
|
|
||||||
apricot: "Abricot",
|
|
||||||
peach: "Pêche",
|
|
||||||
plum: "Prune",
|
|
||||||
grape: "Raisin",
|
|
||||||
melon: "Melon",
|
|
||||||
watermelon: "Pastèque",
|
|
||||||
pineapple: "Ananas",
|
|
||||||
mango: "Mangue",
|
|
||||||
kiwi: "Kiwi",
|
|
||||||
fig: "Figue",
|
|
||||||
date: "Datte",
|
|
||||||
lychee: "Litchi",
|
|
||||||
pomegranate: "Grenade",
|
|
||||||
rhubarb: "Rhubarbe",
|
|
||||||
quince: "Coing",
|
|
||||||
blackcurrant: "Cassis",
|
|
||||||
cranberry: "Canneberge",
|
|
||||||
redcurrant: "Groseille",
|
|
||||||
persimmon: "Kaki",
|
|
||||||
nectarine: "Nectarine",
|
|
||||||
tamarind: "Tamarin",
|
|
||||||
// Fresh herbs
|
|
||||||
basil: "Basilic",
|
|
||||||
parsley: "Persil",
|
|
||||||
thyme: "Thym",
|
|
||||||
rosemary: "Romarin",
|
|
||||||
bayLeaf: "Laurier",
|
|
||||||
chives: "Ciboulette",
|
|
||||||
freshCilantro: "Coriandre fraîche",
|
|
||||||
mint: "Menthe",
|
|
||||||
oregano: "Origan",
|
|
||||||
dill: "Aneth",
|
|
||||||
tarragon: "Estragon",
|
|
||||||
savory: "Sarriette",
|
|
||||||
marjoram: "Marjolaine",
|
|
||||||
sage: "Sauge",
|
|
||||||
chervil: "Cerfeuil",
|
|
||||||
ginger: "Gingembre",
|
|
||||||
lemongrass: "Citronnelle",
|
|
||||||
kaffirLime: "Combava",
|
|
||||||
// Meats
|
|
||||||
rabbit: "Lapin",
|
|
||||||
groundBeef: "Bœuf haché",
|
|
||||||
beefSteak: "Steak de bœuf",
|
|
||||||
beefRoast: "Rôti de bœuf",
|
|
||||||
vealCutlet: "Escalope de veau",
|
|
||||||
porkTenderloin: "Filet mignon de porc",
|
|
||||||
porkChop: "Côte de porc",
|
|
||||||
groundVeal: "Veau haché",
|
|
||||||
groundPork: "Porc haché",
|
|
||||||
groundLamb: "Agneau haché",
|
|
||||||
lamb: "Agneau",
|
|
||||||
legOfLamb: "Gigot d'agneau",
|
|
||||||
baconLardons: "Lardons",
|
|
||||||
bacon: "Bacon",
|
|
||||||
ham: "Jambon blanc",
|
|
||||||
curedHam: "Jambon cru",
|
|
||||||
sausage: "Saucisse",
|
|
||||||
chorizo: "Chorizo",
|
|
||||||
merguez: "Merguez",
|
|
||||||
prosciutto: "Prosciutto",
|
|
||||||
pancetta: "Pancetta",
|
|
||||||
mortadella: "Mortadelle",
|
|
||||||
salami: "Salami",
|
|
||||||
andouille: "Andouille",
|
|
||||||
andouillette: "Andouillette",
|
|
||||||
whitePudding: "Boudin blanc",
|
|
||||||
blackPudding: "Boudin noir",
|
|
||||||
cervelat: "Cervelas",
|
|
||||||
rillettes: "Rillettes",
|
|
||||||
dryCuredSausage: "Saucisson sec",
|
|
||||||
bayonneHam: "Jambon de Bayonne",
|
|
||||||
coppa: "Coppa",
|
|
||||||
rosetteSausage: "Rosette (saucisson)",
|
|
||||||
vealLiver: "Foie de veau",
|
|
||||||
vealKidneys: "Rognons de veau",
|
|
||||||
vealBrain: "Cervelle de veau",
|
|
||||||
vealSweetbread: "Ris de veau",
|
|
||||||
beefTongue: "Langue de bœuf",
|
|
||||||
tripe: "Tripes",
|
|
||||||
venison: "Cerf",
|
|
||||||
roeDeer: "Chevreuil",
|
|
||||||
wildBoar: "Sanglier",
|
|
||||||
horseMeat: "Cheval",
|
|
||||||
beefHeart: "Cœur de bœuf",
|
|
||||||
foieGras: "Foie gras",
|
|
||||||
beefMuzzle: "Museau de bœuf",
|
|
||||||
grisonsDriedBeef: "Viande des Grisons",
|
|
||||||
// Poultry
|
|
||||||
chicken: "Poulet",
|
|
||||||
groundChicken: "Poulet haché",
|
|
||||||
turkey: "Dinde",
|
|
||||||
groundTurkey: "Dinde hachée",
|
|
||||||
duck: "Canard",
|
|
||||||
duckBreast: "Magret de canard",
|
|
||||||
quail: "Caille",
|
|
||||||
guineaFowl: "Pintade",
|
|
||||||
goose: "Oie",
|
|
||||||
poultryLiver: "Foie de volaille",
|
|
||||||
capon: "Chapon",
|
|
||||||
pigeon: "Pigeon",
|
|
||||||
pheasant: "Faisan",
|
|
||||||
// Fish
|
|
||||||
salmon: "Saumon",
|
|
||||||
tuna: "Thon",
|
|
||||||
cod: "Cabillaud",
|
|
||||||
trout: "Truite",
|
|
||||||
sardine: "Sardine",
|
|
||||||
anchovy: "Anchois",
|
|
||||||
whiting: "Merlan",
|
|
||||||
surimi: "Surimi",
|
|
||||||
seaBass: "Bar (loup de mer)",
|
|
||||||
seaBream: "Dorade",
|
|
||||||
sole: "Sole",
|
|
||||||
turbot: "Turbot",
|
|
||||||
hake: "Merlu",
|
|
||||||
pollock: "Colin",
|
|
||||||
saithe: "Lieu noir",
|
|
||||||
haddock: "Églefin",
|
|
||||||
mackerel: "Maquereau",
|
|
||||||
herring: "Hareng",
|
|
||||||
redMullet: "Rouget",
|
|
||||||
skate: "Raie",
|
|
||||||
monkfish: "Lotte",
|
|
||||||
halibut: "Flétan",
|
|
||||||
swordfish: "Espadon",
|
|
||||||
carp: "Carpe",
|
|
||||||
pike: "Brochet",
|
|
||||||
perch: "Perche",
|
|
||||||
tilapia: "Tilapia",
|
|
||||||
pangasius: "Panga",
|
|
||||||
smokedSalmon: "Saumon fumé",
|
|
||||||
driedFish: "Poisson séché",
|
|
||||||
eel: "Anguille",
|
|
||||||
plaice: "Carrelet (ou plie)",
|
|
||||||
saltCod: "Morue",
|
|
||||||
lemonSole: "Limande",
|
|
||||||
scorpionfish: "Rascasse",
|
|
||||||
// Shellfish
|
|
||||||
shrimp: "Crevettes",
|
|
||||||
langoustine: "Langoustines",
|
|
||||||
lobster: "Homard",
|
|
||||||
crab: "Crabe",
|
|
||||||
spinyLobster: "Langouste",
|
|
||||||
mussels: "Moules",
|
|
||||||
oysters: "Huîtres",
|
|
||||||
scallops: "Saint-Jacques",
|
|
||||||
squid: "Calamar",
|
|
||||||
octopus: "Poulpe",
|
|
||||||
clams: "Palourdes",
|
|
||||||
whelks: "Bulots",
|
|
||||||
spiderCrab: "Araignée de mer",
|
|
||||||
periwinkle: "Bigorneau",
|
|
||||||
crayfish: "Écrevisse",
|
|
||||||
greyShrimp: "Crevette grise",
|
|
||||||
cockle: "Coque",
|
|
||||||
snail: "Escargot",
|
|
||||||
cuttlefish: "Seiche",
|
|
||||||
// Starches
|
|
||||||
semolina: "Semoule",
|
|
||||||
couscous: "Couscous",
|
|
||||||
bulgur: "Boulgour",
|
|
||||||
polenta: "Polenta",
|
|
||||||
quinoa: "Quinoa",
|
|
||||||
pasta: "Pâtes",
|
|
||||||
wholeWheatPasta: "Pâtes complètes",
|
|
||||||
rice: "Riz",
|
|
||||||
basmatiRice: "Riz basmati",
|
|
||||||
brownRice: "Riz complet",
|
|
||||||
oats: "Flocons d'avoine",
|
|
||||||
spaghetti: "Spaghetti",
|
|
||||||
penne: "Penne",
|
|
||||||
tagliatelle: "Tagliatelles",
|
|
||||||
lasagnaSheets: "Lasagnes (feuilles)",
|
|
||||||
gnocchi: "Gnocchi",
|
|
||||||
arborioRice: "Riz arborio",
|
|
||||||
riceNoodles: "Nouilles de riz",
|
|
||||||
udonNoodles: "Nouilles udon",
|
|
||||||
sobaNoodles: "Nouilles soba",
|
|
||||||
chineseNoodles: "Nouilles chinoises",
|
|
||||||
riceVermicelli: "Vermicelles de riz",
|
|
||||||
soyVermicelli: "Vermicelles de soja",
|
|
||||||
stickyRice: "Riz gluant",
|
|
||||||
sushiRice: "Riz à sushi",
|
|
||||||
jasmineRice: "Riz jasmin",
|
|
||||||
// Legumes
|
|
||||||
greenLentils: "Lentilles vertes",
|
|
||||||
redLentils: "Lentilles corail",
|
|
||||||
chickpeas: "Pois chiches",
|
|
||||||
whiteBeans: "Haricots blancs",
|
|
||||||
kidneyBeans: "Haricots rouges",
|
|
||||||
blackBeans: "Haricots noirs",
|
|
||||||
splitPeas: "Pois cassés",
|
|
||||||
favaBeans: "Fèves",
|
|
||||||
edamame: "Edamame",
|
|
||||||
pintoBeans: "Haricots pinto",
|
|
||||||
flageoletBeans: "Haricots flageolets",
|
|
||||||
goldenLentils: "Lentilles blondes",
|
|
||||||
// Nuts, seeds and other dry goods
|
|
||||||
peanutsShelled: "Cacahuètes",
|
|
||||||
almonds: "Amandes",
|
|
||||||
walnuts: "Noix",
|
|
||||||
hazelnuts: "Noisettes",
|
|
||||||
cashews: "Noix de cajou",
|
|
||||||
pistachios: "Pistaches",
|
|
||||||
pecans: "Noix de pécan",
|
|
||||||
almondPowder: "Poudre d'amande",
|
|
||||||
pineNuts: "Pignons de pin",
|
|
||||||
sunflowerSeeds: "Graines de tournesol",
|
|
||||||
pumpkinSeeds: "Graines de courge",
|
|
||||||
shreddedCoconut: "Noix de coco râpée",
|
|
||||||
raisins: "Raisins secs",
|
|
||||||
prunes: "Pruneaux",
|
|
||||||
driedApricots: "Abricots secs",
|
|
||||||
sesameSeeds: "Graines de sésame",
|
|
||||||
blackMushrooms: "Champignons noirs",
|
|
||||||
noriSeaweed: "Algue nori",
|
|
||||||
wakameSeaweed: "Algue wakamé",
|
|
||||||
kombuSeaweed: "Algue kombu",
|
|
||||||
bambooShoots: "Pousses de bambou",
|
|
||||||
waterChestnuts: "Châtaignes d'eau",
|
|
||||||
// Breads
|
|
||||||
bread: "Pain",
|
|
||||||
sandwichBread: "Pain de mie",
|
|
||||||
wholeWheatBread: "Pain complet",
|
|
||||||
baguette: "Baguette",
|
|
||||||
ryeBread: "Pain de seigle",
|
|
||||||
breadcrumbs: "Chapelure",
|
|
||||||
burgerBun: "Pain à burger",
|
|
||||||
briocheBun: "Pain brioché",
|
|
||||||
hotDogBun: "Pain à hot-dog",
|
|
||||||
pitaBread: "Pain pita",
|
|
||||||
bagel: "Pain bagel",
|
|
||||||
naan: "Naan",
|
|
||||||
wrapBread: "Pain wrap",
|
|
||||||
vienneseBread: "Pain viennois",
|
|
||||||
countryBread: "Pain de campagne",
|
|
||||||
multigrainBread: "Pain aux céréales",
|
|
||||||
breadRoll: "Petit pain",
|
|
||||||
swedishBread: "Pain suédois",
|
|
||||||
glutenFreeBread: "Pain sans gluten",
|
|
||||||
rusk: "Biscotte",
|
|
||||||
croutons: "Croûtons",
|
|
||||||
focaccia: "Focaccia",
|
|
||||||
ciabatta: "Ciabatta",
|
|
||||||
cornTortilla: "Tortilla de maïs",
|
|
||||||
wheatTortilla: "Tortilla de blé",
|
|
||||||
breadstick: "Gressin",
|
|
||||||
// Raw dough
|
|
||||||
puffPastry: "Pâte feuilletée",
|
|
||||||
shortcrustPastry: "Pâte brisée",
|
|
||||||
pizzaDough: "Pâte à pizza",
|
|
||||||
sweetShortcrustPastry: "Pâte à tarte sablée",
|
|
||||||
// Dairy
|
|
||||||
milk: "Lait",
|
|
||||||
butter: "Beurre",
|
|
||||||
cremeFraiche: "Crème fraîche",
|
|
||||||
liquidCream: "Crème liquide",
|
|
||||||
cheese: "Fromage",
|
|
||||||
emmental: "Emmental",
|
|
||||||
gruyere: "Gruyère",
|
|
||||||
parmesan: "Parmesan",
|
|
||||||
mozzarella: "Mozzarella",
|
|
||||||
goatCheese: "Chèvre (fromage)",
|
|
||||||
feta: "Feta",
|
|
||||||
comte: "Comté",
|
|
||||||
fromageBlanc: "Fromage blanc",
|
|
||||||
mascarpone: "Mascarpone",
|
|
||||||
yogurt: "Yaourt",
|
|
||||||
burrata: "Burrata",
|
|
||||||
ricotta: "Ricotta",
|
|
||||||
pecorino: "Pecorino",
|
|
||||||
gorgonzola: "Gorgonzola",
|
|
||||||
cheddar: "Cheddar",
|
|
||||||
brie: "Brie",
|
|
||||||
camembert: "Camembert",
|
|
||||||
roquefort: "Roquefort",
|
|
||||||
munster: "Munster",
|
|
||||||
reblochon: "Reblochon",
|
|
||||||
cantal: "Cantal",
|
|
||||||
beaufort: "Beaufort",
|
|
||||||
saintNectaire: "Saint-Nectaire",
|
|
||||||
blueCheese: "Bleu (fromage)",
|
|
||||||
cancoillotte: "Cancoillotte",
|
|
||||||
tomme: "Tomme",
|
|
||||||
epoisses: "Époisses",
|
|
||||||
chaource: "Chaource",
|
|
||||||
livarot: "Livarot",
|
|
||||||
pontLeveque: "Pont-l'Évêque",
|
|
||||||
morbier: "Morbier",
|
|
||||||
racletteCheese: "Raclette (fromage)",
|
|
||||||
fourmeDAmbert: "Fourme d'Ambert",
|
|
||||||
salers: "Salers",
|
|
||||||
ossauIraty: "Ossau-Iraty",
|
|
||||||
vacherin: "Vacherin",
|
|
||||||
saintMarcellin: "Saint-Marcellin",
|
|
||||||
neufchatel: "Neufchâtel",
|
|
||||||
crottinDeChavignol: "Crottin de Chavignol",
|
|
||||||
abondanceCheese: "Abondance",
|
|
||||||
carreDeLEst: "Carré de l'Est",
|
|
||||||
edam: "Edam",
|
|
||||||
gouda: "Gouda",
|
|
||||||
mimolette: "Mimolette",
|
|
||||||
maroilles: "Maroilles",
|
|
||||||
montDor: "Mont d'or",
|
|
||||||
kefir: "Kéfir",
|
|
||||||
greekYogurt: "Yaourt à la grecque",
|
|
||||||
// Eggs
|
|
||||||
egg: "Oeuf",
|
|
||||||
eggYolk: "Jaune d'oeuf",
|
|
||||||
eggWhite: "Blanc d'oeuf",
|
|
||||||
// Plant-based alternatives
|
|
||||||
coconutMilk: "Lait de coco",
|
|
||||||
coconutCream: "Crème de coco",
|
|
||||||
almondMilk: "Lait d'amande",
|
|
||||||
oatMilk: "Lait d'avoine",
|
|
||||||
tofu: "Tofu",
|
|
||||||
silkenTofu: "Tofu soyeux",
|
|
||||||
// Spices
|
|
||||||
herbesDeProvence: "Herbes de Provence",
|
|
||||||
blackPepper: "Poivre noir",
|
|
||||||
paprika: "Paprika",
|
|
||||||
espelettePepper: "Piment d'Espelette",
|
|
||||||
cayennePepper: "Piment de Cayenne",
|
|
||||||
cumin: "Cumin",
|
|
||||||
curryPowder: "Curry (poudre)",
|
|
||||||
turmeric: "Curcuma",
|
|
||||||
cinnamon: "Cannelle",
|
|
||||||
nutmeg: "Muscade",
|
|
||||||
saffron: "Safran",
|
|
||||||
clove: "Clou de girofle",
|
|
||||||
vanillaBean: "Vanille (gousse)",
|
|
||||||
whitePepper: "Poivre blanc",
|
|
||||||
pinkPepper: "Poivre rose",
|
|
||||||
sichuanPepper: "Poivre du Sichuan",
|
|
||||||
smokedPaprika: "Paprika fumé",
|
|
||||||
birdEyeChili: "Piment oiseau",
|
|
||||||
juniperBerries: "Baies de genièvre",
|
|
||||||
starAnise: "Anis étoilé (badiane)",
|
|
||||||
greenAnise: "Anis vert",
|
|
||||||
fennelSeeds: "Graines de fenouil",
|
|
||||||
sumac: "Sumac",
|
|
||||||
nigella: "Nigelle",
|
|
||||||
allspice: "Quatre épices",
|
|
||||||
colomboPowder: "Colombo (poudre)",
|
|
||||||
baharat: "Baharat",
|
|
||||||
horseradish: "Raifort",
|
|
||||||
herbSalt: "Sel aux herbes",
|
|
||||||
celerySalt: "Sel de céleri",
|
|
||||||
fleurDeSel: "Fleur de sel",
|
|
||||||
salt: "Sel",
|
|
||||||
fiveSpice: "Cinq épices",
|
|
||||||
garamMasala: "Garam masala",
|
|
||||||
corianderSeeds: "Graines de coriandre",
|
|
||||||
groundCoriander: "Coriandre en poudre",
|
|
||||||
cardamom: "Cardamome",
|
|
||||||
fenugreek: "Fenugrec",
|
|
||||||
jalapeno: "Piment jalapeño",
|
|
||||||
chipotle: "Piment chipotle",
|
|
||||||
poblanoPepper: "Piment poblano",
|
|
||||||
habanero: "Piment habanero",
|
|
||||||
rasElHanout: "Ras el hanout",
|
|
||||||
zaatar: "Za'atar",
|
|
||||||
// Sauces
|
|
||||||
soySauce: "Sauce soja",
|
|
||||||
mustard: "Moutarde",
|
|
||||||
mayonnaise: "Mayonnaise",
|
|
||||||
ketchup: "Ketchup",
|
|
||||||
tabasco: "Tabasco",
|
|
||||||
worcestershireSauce: "Sauce Worcestershire",
|
|
||||||
fishSauce: "Sauce nuoc-mâm",
|
|
||||||
wasabi: "Wasabi",
|
|
||||||
harissa: "Harissa",
|
|
||||||
curryPaste: "Pâte de curry",
|
|
||||||
peanutButter: "Beurre de cacahuète",
|
|
||||||
dijonMustard: "Moutarde de Dijon",
|
|
||||||
wholegrainMustard: "Moutarde à l'ancienne",
|
|
||||||
barbecueSauce: "Sauce barbecue",
|
|
||||||
tartarSauce: "Sauce tartare",
|
|
||||||
cocktailSauce: "Sauce cocktail",
|
|
||||||
bearnaiseSauce: "Sauce béarnaise",
|
|
||||||
hollandaiseSauce: "Sauce hollandaise",
|
|
||||||
bechamelSauce: "Sauce béchamel",
|
|
||||||
teriyakiSauce: "Sauce teriyaki",
|
|
||||||
ponzuSauce: "Sauce ponzu",
|
|
||||||
chimichurri: "Chimichurri",
|
|
||||||
redPesto: "Pesto rouge (tomates séchées)",
|
|
||||||
pesto: "Pesto",
|
|
||||||
oysterSauce: "Sauce huître",
|
|
||||||
hoisinSauce: "Sauce hoisin",
|
|
||||||
sriracha: "Sauce sriracha",
|
|
||||||
sweetChiliSauce: "Sauce sweet chili",
|
|
||||||
miso: "Miso",
|
|
||||||
shrimpPaste: "Pâte de crevettes",
|
|
||||||
redCurryPaste: "Pâte de curry rouge (thaï)",
|
|
||||||
greenCurryPaste: "Pâte de curry vert (thaï)",
|
|
||||||
tahini: "Tahini",
|
|
||||||
aioli: "Aïoli",
|
|
||||||
vinaigrette: "Sauce vinaigrette",
|
|
||||||
hummus: "Houmous",
|
|
||||||
// Seasonings — oils, vinegars, wines and other flavorings
|
|
||||||
oliveOil: "Huile d'olive",
|
|
||||||
sunflowerOil: "Huile de tournesol",
|
|
||||||
rapeseedOil: "Huile de colza",
|
|
||||||
coconutOil: "Huile de coco",
|
|
||||||
sesameOil: "Huile de sésame",
|
|
||||||
ciderVinegar: "Vinaigre de cidre",
|
|
||||||
whiteVinegar: "Vinaigre blanc",
|
|
||||||
balsamicVinegar: "Vinaigre balsamique",
|
|
||||||
capers: "Câpres",
|
|
||||||
olives: "Olives",
|
|
||||||
blackOlives: "Olives noires",
|
|
||||||
greenOlives: "Olives vertes",
|
|
||||||
whiteWine: "Vin blanc (cuisine)",
|
|
||||||
redWine: "Vin rouge (cuisine)",
|
|
||||||
roseWine: "Vin rosé (cuisine)",
|
|
||||||
redWineVinegar: "Vinaigre de vin rouge",
|
|
||||||
whiteWineVinegar: "Vinaigre de vin blanc",
|
|
||||||
sherryVinegar: "Vinaigre de xérès",
|
|
||||||
walnutOil: "Huile de noix",
|
|
||||||
hazelnutOil: "Huile de noisette",
|
|
||||||
peanutOil: "Huile d'arachide",
|
|
||||||
chiliOil: "Huile pimentée",
|
|
||||||
riceVinegar: "Vinaigre de riz",
|
|
||||||
cornOil: "Huile de maïs",
|
|
||||||
grapeseedOil: "Huile de pépins de raisin",
|
|
||||||
soybeanOil: "Huile de soja",
|
|
||||||
palmOil: "Huile de palme",
|
|
||||||
mirin: "Mirin",
|
|
||||||
sake: "Saké (cuisine)",
|
|
||||||
lemonJuice: "Jus de citron",
|
|
||||||
limeJuice: "Jus de citron vert",
|
|
||||||
orangeJuice: "Jus d'orange",
|
|
||||||
appleJuice: "Jus de pomme",
|
|
||||||
grapeJuice: "Jus de raisin",
|
|
||||||
tomatoJuice: "Jus de tomate",
|
|
||||||
cranberryJuice: "Jus de cranberry",
|
|
||||||
coffee: "Café",
|
|
||||||
tea: "Thé",
|
|
||||||
beer: "Bière (cuisine)",
|
|
||||||
cider: "Cidre (cuisine)",
|
|
||||||
champagne: "Champagne / vin pétillant (cuisine)",
|
|
||||||
portWine: "Porto (cuisine)",
|
|
||||||
vinJaune: "Vin jaune (cuisine)",
|
|
||||||
cognac: "Cognac",
|
|
||||||
rum: "Rhum",
|
|
||||||
whisky: "Whisky",
|
|
||||||
vodka: "Vodka",
|
|
||||||
// Bases — flours, stocks and other cooking essentials
|
|
||||||
wheatFlour: "Farine de blé",
|
|
||||||
wholeWheatFlour: "Farine complète",
|
|
||||||
cornFlour: "Farine de maïs",
|
|
||||||
buckwheatFlour: "Farine de sarrasin",
|
|
||||||
riceFlour: "Farine de riz",
|
|
||||||
vegetableStockCube: "Bouillon cube légumes",
|
|
||||||
chickenStockCube: "Bouillon cube volaille",
|
|
||||||
tomatoPaste: "Concentré de tomate",
|
|
||||||
tomatoCoulis: "Coulis de tomate",
|
|
||||||
cannedPeeledTomatoes: "Tomates pelées (conserve)",
|
|
||||||
sunDriedTomatoes: "Tomates séchées",
|
|
||||||
vealStock: "Fond de veau",
|
|
||||||
chickenStock: "Fond de volaille",
|
|
||||||
beefStockCube: "Bouillon cube bœuf",
|
|
||||||
fishStockCube: "Bouillon cube poisson",
|
|
||||||
vegetableBroth: "Bouillon de légumes",
|
|
||||||
chickenBroth: "Bouillon de volaille",
|
|
||||||
beefBroth: "Bouillon de bœuf",
|
|
||||||
courtBouillon: "Court-bouillon",
|
|
||||||
dashi: "Dashi (bouillon japonais)",
|
|
||||||
shellfishBisque: "Bisque de crustacés",
|
|
||||||
tapiocaFlour: "Farine de tapioca",
|
|
||||||
masaHarina: "Masa harina",
|
|
||||||
water: "Eau",
|
|
||||||
sparklingWater: "Eau gazeuse",
|
|
||||||
orangeBlossomWater: "Eau de fleur d'oranger",
|
|
||||||
roseWater: "Eau de rose",
|
|
||||||
fishFumet: "Fumet de poisson",
|
|
||||||
// Thickeners and raising agents
|
|
||||||
bakersYeast: "Levure boulangère",
|
|
||||||
bakingPowder: "Levure chimique",
|
|
||||||
cornstarch: "Maïzena",
|
|
||||||
lupinFlour: "Farine de lupin",
|
|
||||||
gelatin: "Gélatine",
|
|
||||||
bakingSoda: "Bicarbonate de soude",
|
|
||||||
potatoStarch: "Fécule de pomme de terre",
|
|
||||||
// Sugars
|
|
||||||
sugar: "Sucre",
|
|
||||||
honey: "Miel",
|
|
||||||
mapleSyrup: "Sirop d'érable",
|
|
||||||
brownSugar: "Sucre roux",
|
|
||||||
powderedSugar: "Sucre glace",
|
|
||||||
demeraraSugar: "Cassonade",
|
|
||||||
darkChocolate: "Chocolat noir",
|
|
||||||
milkChocolate: "Chocolat au lait",
|
|
||||||
whiteChocolate: "Chocolat blanc",
|
|
||||||
chocolateChips: "Pépites de chocolat",
|
|
||||||
cocoaPowder: "Cacao en poudre",
|
|
||||||
vanillaExtract: "Extrait de vanille",
|
|
||||||
palmSugar: "Sucre de palme",
|
|
||||||
caneSyrup: "Sirop de sucre de canne",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extra French matching phrases for a handful of {@link INGREDIENT_LABELS_FR}
|
|
||||||
* entries whose primary (display) label doesn't match how the phrase is
|
|
||||||
* actually written in running recipe text — see that constant's own doc
|
|
||||||
* comment for why this is expected to grow over time, the same "exceptions
|
|
||||||
* only, not every entry" shape as {@link INGREDIENT_LABEL_SYNONYMS_EN}.
|
|
||||||
*/
|
|
||||||
export const INGREDIENT_LABEL_SYNONYMS_FR: Record<string, string[]> = {
|
|
||||||
// "Vanille (gousse)" is display-first-word-last; real recipe text says
|
|
||||||
// "gousse de vanille" (pod word first) — see INGREDIENT_LABELS_FR's doc
|
|
||||||
// comment.
|
|
||||||
vanillaBean: ["Gousse de vanille"],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* French matching synonyms for the `Unit` reference catalog
|
|
||||||
* (`apps/api/src/db/reference-seed-data.ts`'s `UNITS`), keyed by
|
|
||||||
* `Unit.key` — the French counterpart to {@link UNIT_LABELS_EN}. Unlike
|
|
||||||
* {@link INGREDIENT_LABELS_FR}, these are **not** copied from
|
|
||||||
* `apps/web`'s locale file: that file has exactly one display label per
|
|
||||||
* unit (`apps/web/src/locales/fr/translation.json`'s `catalog.units`,
|
|
||||||
* e.g. `tablespoon`: "cuillère à soupe"), fine for a dropdown but not
|
|
||||||
* enough to *match* French recipe text against — real recipes freely mix
|
|
||||||
* the full phrase, its plural, and common abbreviations ("cuillère à
|
|
||||||
* soupe", "cuillères à soupe", "c. à soupe", "càs" all appear in the
|
|
||||||
* wild), so each entry here is hand-authored the same way
|
|
||||||
* {@link UNIT_LABELS_EN} was, starting from that same display label.
|
|
||||||
*
|
|
||||||
* Several of these are genuinely multi-word ("cuillère à soupe") — unlike
|
|
||||||
* English units, which are always a single word/abbreviation. Matching a
|
|
||||||
* multi-word unit needs the same ordered-contiguous-run search
|
|
||||||
* `matchIngredientName` already does for ingredients, not the older
|
|
||||||
* single-first-word check `matchUnit` used before French support existed
|
|
||||||
* — see `matchUnit`'s own doc comment (`ingredient-matcher.ts`) for the
|
|
||||||
* bug that would otherwise cause: a multi-word French synonym's *whole
|
|
||||||
* phrase* (spaces and all) would never equal a single extracted word, so
|
|
||||||
* it could never match anything at all.
|
|
||||||
*/
|
|
||||||
export const UNIT_LABELS_FR: Record<string, string[]> = {
|
|
||||||
gram: ["g", "gr", "gramme", "grammes"],
|
|
||||||
kilogram: ["kg", "kilo", "kilos", "kilogramme", "kilogrammes"],
|
|
||||||
milliliter: ["ml", "millilitre", "millilitres"],
|
|
||||||
centiliter: ["cl", "centilitre", "centilitres"],
|
|
||||||
liter: ["l", "litre", "litres"],
|
|
||||||
tablespoon: ["cuillère à soupe", "cuillères à soupe", "c. à soupe", "c à soupe", "cas", "càs"],
|
|
||||||
teaspoon: ["cuillère à café", "cuillères à café", "c. à café", "c à café", "cac", "càc"],
|
|
||||||
piece: ["unité", "unités", "pièce", "pièces"],
|
|
||||||
pinch: ["pincée", "pincées"],
|
|
||||||
slice: ["tranche", "tranches"],
|
|
||||||
clove: ["gousse", "gousses"],
|
|
||||||
bunch: ["botte", "bottes"],
|
|
||||||
sachet: ["sachet", "sachets"],
|
|
||||||
sprig: ["brin", "brins"],
|
|
||||||
cup: ["tasse", "tasses"],
|
|
||||||
ounce: ["once", "onces"],
|
|
||||||
pound: ["livre", "livres"],
|
|
||||||
};
|
|
||||||
|
|
@ -68,8 +68,6 @@ export enum ErrorCode {
|
||||||
STEP_NOT_FOUND = 4050,
|
STEP_NOT_FOUND = 4050,
|
||||||
/** A tech-step correction's `previousTechStepId`/`correctedTechStepId` doesn't match any reference `TechStep` row. */
|
/** A tech-step correction's `previousTechStepId`/`correctedTechStepId` doesn't match any reference `TechStep` row. */
|
||||||
TECH_STEP_NOT_FOUND = 4051,
|
TECH_STEP_NOT_FOUND = 4051,
|
||||||
/** A tech-step correction's manually-attached `utensils[].utensilId` doesn't match any reference `Utensil` row. */
|
|
||||||
UTENSIL_NOT_FOUND = 4052,
|
|
||||||
/** A tech-step correction's `start`/`end` span falls outside the target step's `description`, or `start >= end`. */
|
/** A tech-step correction's `start`/`end` span falls outside the target step's `description`, or `start >= end`. */
|
||||||
INVALID_CORRECTION_SPAN = 4002,
|
INVALID_CORRECTION_SPAN = 4002,
|
||||||
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
// detail specific to one side.
|
// detail specific to one side.
|
||||||
|
|
||||||
export * from "./data/catalog-labels-en.js";
|
export * from "./data/catalog-labels-en.js";
|
||||||
export * from "./data/catalog-labels-fr.js";
|
|
||||||
export * from "./errors/error-codes.js";
|
export * from "./errors/error-codes.js";
|
||||||
export * from "./schemas/account.js";
|
export * from "./schemas/account.js";
|
||||||
export * from "./schemas/auth.js";
|
export * from "./schemas/auth.js";
|
||||||
|
|
@ -13,7 +12,6 @@ export * from "./schemas/planning.js";
|
||||||
export * from "./schemas/preferences.js";
|
export * from "./schemas/preferences.js";
|
||||||
export * from "./schemas/profile.js";
|
export * from "./schemas/profile.js";
|
||||||
export * from "./schemas/recipe.js";
|
export * from "./schemas/recipe.js";
|
||||||
export * from "./schemas/shopping-list.js";
|
|
||||||
export * from "./schemas/sources.js";
|
export * from "./schemas/sources.js";
|
||||||
export * from "./schemas/tech-step-worker.js";
|
export * from "./schemas/tech-step-worker.js";
|
||||||
export * from "./tools/assert-is-never.js";
|
export * from "./tools/assert-is-never.js";
|
||||||
|
|
@ -22,7 +20,6 @@ export * from "./types/planning.js";
|
||||||
export * from "./types/preferences.js";
|
export * from "./types/preferences.js";
|
||||||
export * from "./types/recipe.js";
|
export * from "./types/recipe.js";
|
||||||
export * from "./types/reference.js";
|
export * from "./types/reference.js";
|
||||||
export * from "./types/shopping-list.js";
|
|
||||||
export * from "./types/sources.js";
|
export * from "./types/sources.js";
|
||||||
export * from "./types/tech-step-worker.js";
|
export * from "./types/tech-step-worker.js";
|
||||||
export * from "./types/user-profile.js";
|
export * from "./types/user-profile.js";
|
||||||
|
|
|
||||||
|
|
@ -117,41 +117,6 @@ export const listRecipesSchema = z.object({
|
||||||
/** Inferred TS type for {@link listRecipesSchema}'s validated output. */
|
/** Inferred TS type for {@link listRecipesSchema}'s validated output. */
|
||||||
export type ListRecipesInput = z.infer<typeof listRecipesSchema>;
|
export type ListRecipesInput = z.infer<typeof listRecipesSchema>;
|
||||||
|
|
||||||
/**
|
|
||||||
* One ingredient mention the user themselves points at while correcting a
|
|
||||||
* technique — `start`/`end` is *their own* selection of the exact passage
|
|
||||||
* of `description` that names it (a separate selection from the
|
|
||||||
* correction's own `[start, end)`, see `TechStepCorrectionPopover.tsx`),
|
|
||||||
* not derived from anything the classifier found. `quantity`/`unitId`
|
|
||||||
* are optional — a mention with no quantity attached ("ajouter le sel")
|
|
||||||
* is still worth recording. See `submitTechStepCorrectionSchema`'s own
|
|
||||||
* doc comment for how `ingredients` as a whole behaves.
|
|
||||||
*/
|
|
||||||
const manualStepTechStepIngredientInputSchema = z
|
|
||||||
.object({
|
|
||||||
ingredientId: z.number().int().positive(),
|
|
||||||
quantity: z.number().positive("La quantité doit être positive").nullable().optional(),
|
|
||||||
unitId: z.number().int().positive().nullable().optional(),
|
|
||||||
start: z.number().int().nonnegative(),
|
|
||||||
end: z.number().int().nonnegative(),
|
|
||||||
})
|
|
||||||
.refine((ingredient) => ingredient.end > ingredient.start, {
|
|
||||||
message: "end must be greater than start",
|
|
||||||
path: ["end"],
|
|
||||||
});
|
|
||||||
|
|
||||||
/** A utensil mention the user points at while correcting a technique — same `start`/`end` convention as {@link manualStepTechStepIngredientInputSchema}, no quantity/unit (nothing to measure for a utensil). */
|
|
||||||
const manualStepTechStepUtensilInputSchema = z
|
|
||||||
.object({
|
|
||||||
utensilId: z.number().int().positive(),
|
|
||||||
start: z.number().int().nonnegative(),
|
|
||||||
end: z.number().int().nonnegative(),
|
|
||||||
})
|
|
||||||
.refine((utensil) => utensil.end > utensil.start, {
|
|
||||||
message: "end must be greater than start",
|
|
||||||
path: ["end"],
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload accepted by `POST /recipes/:id/steps/:stepId/corrections` — a
|
* Payload accepted by `POST /recipes/:id/steps/:stepId/corrections` — a
|
||||||
* user asserting what technique a `[start, end)` span of a step's
|
* user asserting what technique a `[start, end)` span of a step's
|
||||||
|
|
@ -164,19 +129,6 @@ const manualStepTechStepUtensilInputSchema = z
|
||||||
* (`recipe-tech-step-correction.service.ts`) — needs the target step's
|
* (`recipe-tech-step-correction.service.ts`) — needs the target step's
|
||||||
* `description` length to validate `start`/`end` against, which this shape
|
* `description` length to validate `start`/`end` against, which this shape
|
||||||
* alone can't see.
|
* alone can't see.
|
||||||
*
|
|
||||||
* `ingredients`/`utensils` let the user attach metadata to the technique
|
|
||||||
* they're asserting (`correctedTechStepId`), same `source: "manual"`
|
|
||||||
* distinction the technique itself gets. **Omitted (`undefined`) means
|
|
||||||
* "leave whatever metadata already exists on this occurrence alone" —
|
|
||||||
* an explicit array, even `[]`, means "this is now the complete set,
|
|
||||||
* replace everything that was there" (auto-detected included; see
|
|
||||||
* `applyManualCorrection`'s own doc comment). This is why neither field
|
|
||||||
* has a `.default([])`: that would silently turn every plain relabel into
|
|
||||||
* a metadata wipe.** Only meaningful alongside a real `correctedTechStepId`
|
|
||||||
* — enforced by this schema's own refine below, since there's no live
|
|
||||||
* `StepTechStep` row to attach to otherwise (removing a match, or a
|
|
||||||
* request with neither id set).
|
|
||||||
*/
|
*/
|
||||||
export const submitTechStepCorrectionSchema = z
|
export const submitTechStepCorrectionSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|
@ -184,8 +136,6 @@ export const submitTechStepCorrectionSchema = z
|
||||||
end: z.number().int().nonnegative(),
|
end: z.number().int().nonnegative(),
|
||||||
previousTechStepId: z.number().int().positive().nullable().optional(),
|
previousTechStepId: z.number().int().positive().nullable().optional(),
|
||||||
correctedTechStepId: z.number().int().positive().nullable().optional(),
|
correctedTechStepId: z.number().int().positive().nullable().optional(),
|
||||||
ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(),
|
|
||||||
utensils: z.array(manualStepTechStepUtensilInputSchema).optional(),
|
|
||||||
})
|
})
|
||||||
.refine((input) => input.end > input.start, {
|
.refine((input) => input.end > input.start, {
|
||||||
message: "end must be greater than start",
|
message: "end must be greater than start",
|
||||||
|
|
@ -198,15 +148,6 @@ export const submitTechStepCorrectionSchema = z
|
||||||
message: "at least one of previousTechStepId/correctedTechStepId is required",
|
message: "at least one of previousTechStepId/correctedTechStepId is required",
|
||||||
path: ["correctedTechStepId"],
|
path: ["correctedTechStepId"],
|
||||||
},
|
},
|
||||||
)
|
|
||||||
.refine(
|
|
||||||
(input) =>
|
|
||||||
(input.ingredients === undefined && input.utensils === undefined) ||
|
|
||||||
(input.correctedTechStepId ?? null) !== null,
|
|
||||||
{
|
|
||||||
message: "ingredients/utensils require a correctedTechStepId to attach to",
|
|
||||||
path: ["correctedTechStepId"],
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
/** Inferred TS type for {@link submitTechStepCorrectionSchema}'s validated output. */
|
/** Inferred TS type for {@link submitTechStepCorrectionSchema}'s validated output. */
|
||||||
export type SubmitTechStepCorrectionInput = z.infer<typeof submitTechStepCorrectionSchema>;
|
export type SubmitTechStepCorrectionInput = z.infer<typeof submitTechStepCorrectionSchema>;
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
// See schemas/auth.ts for the shared client/server validation rationale.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Payload accepted by `GET /shopping-list`'s `?date=` query param — same
|
|
||||||
* shape/rationale as `schemas/planning.ts`'s `getPlanningByDateSchema`
|
|
||||||
* (only checks the `YYYY-MM-DD` shape, real-calendar-date validation is
|
|
||||||
* service-side via `@batch-cooking/date-tools`'s `parseDateOnly`). Kept as
|
|
||||||
* its own schema rather than importing `getPlanningByDateSchema` — each
|
|
||||||
* router module owns its own request contract in this repo, even when two
|
|
||||||
* happen to share a shape (see the two near-identical `date` fields already
|
|
||||||
* inside `schemas/planning.ts` itself).
|
|
||||||
*/
|
|
||||||
export const getShoppingListSchema = z.object({
|
|
||||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date invalide"),
|
|
||||||
});
|
|
||||||
/** Inferred TS type for {@link getShoppingListSchema}'s validated output. */
|
|
||||||
export type GetShoppingListInput = z.infer<typeof getShoppingListSchema>;
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
import type {
|
import type { AllergyView, DietView, IngredientView, TechStepView, UnitView } from "./reference.js";
|
||||||
AllergyView,
|
|
||||||
DietView,
|
|
||||||
IngredientView,
|
|
||||||
TechStepView,
|
|
||||||
UnitView,
|
|
||||||
UtensilView,
|
|
||||||
} from "./reference.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Who can *read* a recipe — mirrors `RecipeVisibility` in schema.prisma.
|
* Who can *read* a recipe — mirrors `RecipeVisibility` in schema.prisma.
|
||||||
|
|
@ -56,11 +49,6 @@ export interface RecipeIngredientView {
|
||||||
* immediately (`recipe-tech-step-correction.service.ts`'s
|
* immediately (`recipe-tech-step-correction.service.ts`'s
|
||||||
* `applyManualCorrection`). `StepDescription.tsx` renders the two with a
|
* `applyManualCorrection`). `StepDescription.tsx` renders the two with a
|
||||||
* different highlight color so a viewer can tell which is which.
|
* different highlight color so a viewer can tell which is which.
|
||||||
*
|
|
||||||
* `ingredients`/`utensils` are the metadata found in this technique's own
|
|
||||||
* clause (see `tech-step-matcher.ts`'s `TechStepMatch` — same source data,
|
|
||||||
* just resolved to full reference views here instead of bare ids) — `[]`
|
|
||||||
* when nothing was mentioned alongside this technique.
|
|
||||||
*/
|
*/
|
||||||
export interface StepTechStepView {
|
export interface StepTechStepView {
|
||||||
techStep: TechStepView;
|
techStep: TechStepView;
|
||||||
|
|
@ -69,38 +57,6 @@ export interface StepTechStepView {
|
||||||
contextStart?: number;
|
contextStart?: number;
|
||||||
contextEnd?: number;
|
contextEnd?: number;
|
||||||
source: "auto" | "manual";
|
source: "auto" | "manual";
|
||||||
ingredients: StepTechStepIngredientView[];
|
|
||||||
utensils: StepTechStepUtensilView[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An ingredient mentioned in the same clause as a detected technique (see
|
|
||||||
* {@link StepTechStepView.ingredients}) — `quantity`/`unit` are `null` when
|
|
||||||
* none was recognized immediately before the mention (e.g. "ajouter le
|
|
||||||
* sel"), same "best-effort, not always present" contract as
|
|
||||||
* `tech-step-matcher.ts`'s `IngredientMention`. `start`/`end` are the
|
|
||||||
* mention's own span in the step's `description`, same `[start, end)`
|
|
||||||
* convention as {@link StepTechStepView.start}.
|
|
||||||
*
|
|
||||||
* `source` mirrors {@link StepTechStepView.source} — `"auto"` is the
|
|
||||||
* classifier's own detection, `"manual"` is a viewer's own selection
|
|
||||||
* (`SubmitTechStepCorrectionInput.ingredients`, `TechStepCorrectionPopover.tsx`).
|
|
||||||
*/
|
|
||||||
export interface StepTechStepIngredientView {
|
|
||||||
ingredient: IngredientView;
|
|
||||||
quantity: number | null;
|
|
||||||
unit: UnitView | null;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
source: "auto" | "manual";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A utensil mentioned in the same clause as a detected technique (see {@link StepTechStepView.utensils}) — `source` mirrors {@link StepTechStepIngredientView.source}. */
|
|
||||||
export interface StepTechStepUtensilView {
|
|
||||||
utensil: UtensilView;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
source: "auto" | "manual";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -210,24 +210,6 @@ export interface TechStepView {
|
||||||
key: string;
|
key: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A cooking utensil, as returned by `GET /reference/utensils` — reference
|
|
||||||
* data (`Utensil`, seeded via `reference-seed-data.ts`'s `UTENSILS`), same
|
|
||||||
* bare `id`+`key` shape and static/non-administrable status as
|
|
||||||
* {@link TechStepView}. Detected in a step's free text the same way
|
|
||||||
* techniques are (see `StepTechStepUtensilView`), but via a static
|
|
||||||
* `PhraseMatcher` rather than a trained classifier — see
|
|
||||||
* `services/tech-step-intent-service`'s `utensil_vocabulary.py`.
|
|
||||||
*
|
|
||||||
* `key` is a stable English camelCase uid (e.g. `"pan"`), not a display
|
|
||||||
* label — resolved via `t(\`catalog.utensils.${key}\`)`, same as
|
|
||||||
* {@link TechStepView.key}.
|
|
||||||
*/
|
|
||||||
export interface UtensilView {
|
|
||||||
id: number;
|
|
||||||
key: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An implemented recipe source, as returned by `GET /reference/sources` —
|
* An implemented recipe source, as returned by `GET /reference/sources` —
|
||||||
* reference data (`Source`, kept in sync with the adapter registry by
|
* reference data (`Source`, kept in sync with the adapter registry by
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import type { IngredientView, UnitView } from "./reference.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One aggregated ingredient line in a shopping list — every
|
|
||||||
* `RecipeIngredient` line of every recipe planned for the week, summed
|
|
||||||
* across recipes/planning items. `quantity` already accounts for each
|
|
||||||
* planning item's own portion count (`RecipeIngredient.quantity ×
|
|
||||||
* PlanningItem.portions / Recipe.portions`, see the API's
|
|
||||||
* `shopping-list.service.ts`), so this is the real amount to buy, not the
|
|
||||||
* recipe's as-written quantity.
|
|
||||||
*
|
|
||||||
* Quantities are only ever summed when both `ingredient` **and** `unit`
|
|
||||||
* match exactly — `UnitView.toBaseFactor` exists as groundwork for a future
|
|
||||||
* cross-unit conversion (e.g. summing "500g" + "0.5kg" into "1kg"), not yet
|
|
||||||
* built (see that field's own doc comment), so the same ingredient
|
|
||||||
* requested in two different units surfaces as two separate lines rather
|
|
||||||
* than silently guessing a conversion.
|
|
||||||
*/
|
|
||||||
export interface ShoppingListItemView {
|
|
||||||
ingredient: IngredientView;
|
|
||||||
quantity: number;
|
|
||||||
unit: UnitView;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A household's shopping list for the week starting `startDate`, as
|
|
||||||
* returned by `GET /shopping-list`. Unlike `PlanningView`, this is
|
|
||||||
* **never** `null` — a caller with no household, or whose household has no
|
|
||||||
* planning for that week yet, both degrade to an empty `items` array
|
|
||||||
* (nothing to shop for is a normal state to render directly, not a
|
|
||||||
* separate "no list" case to branch on).
|
|
||||||
*/
|
|
||||||
export interface ShoppingListView {
|
|
||||||
startDate: string;
|
|
||||||
finishDate: string;
|
|
||||||
items: ShoppingListItemView[];
|
|
||||||
}
|
|
||||||
696
pnpm-lock.yaml
696
pnpm-lock.yaml
|
|
@ -44,6 +44,9 @@ importers:
|
||||||
jsonwebtoken:
|
jsonwebtoken:
|
||||||
specifier: ^9.0.3
|
specifier: ^9.0.3
|
||||||
version: 9.0.3
|
version: 9.0.3
|
||||||
|
node-nlp:
|
||||||
|
specifier: 4.27.0
|
||||||
|
version: 4.27.0
|
||||||
prisma:
|
prisma:
|
||||||
specifier: ^5.22.0
|
specifier: ^5.22.0
|
||||||
version: 5.22.0
|
version: 5.22.0
|
||||||
|
|
@ -850,12 +853,224 @@ packages:
|
||||||
resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==, tarball: https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz}
|
resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==, tarball: https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-choice@1.3.1':
|
||||||
|
resolution: {integrity: sha512-HubunMJVq/OetmdvcAmBh5skMlg+yiScm3V2wNyNZIVvLgli4+8nzbg/W/fI9dpaf6wv9ZQ7d2IYvn8swJBo3A==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-choice/-/recognizers-text-choice-1.3.1.tgz}
|
||||||
|
engines: {node: '>=10.3.0'}
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-data-types-timex-expression@1.3.1':
|
||||||
|
resolution: {integrity: sha512-jarJIFIJZBqeofy3hh0vdQo1yOmTM+jCjj6/zmo9JunsQ6LO750eZHCg9eLptQhsvq321XCt5xdRNLCwU8YeNA==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-data-types-timex-expression/-/recognizers-text-data-types-timex-expression-1.3.1.tgz}
|
||||||
|
engines: {node: '>=10.3.0'}
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-date-time@1.3.2':
|
||||||
|
resolution: {integrity: sha512-fUEGOTccS55ZY0erzjS1bunJYA9lGXjcZoru5oPOlnxbJS4Lk0ylgdH2Ub2EjAyqr8DIJhdLNOEesCdAXMvlNg==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-date-time/-/recognizers-text-date-time-1.3.2.tgz}
|
||||||
|
engines: {node: '>=10.3.0'}
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-number-with-unit@1.3.1':
|
||||||
|
resolution: {integrity: sha512-gzCpPP4zQ5Vb+RHaWjzP2t1c+mj6GYOsFoI2NyJkm8OZ52XI+x9SJCgrrD2ujzjOd5/CQVC46rE22rfGwXLDkA==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-number-with-unit/-/recognizers-text-number-with-unit-1.3.1.tgz}
|
||||||
|
engines: {node: '>=10.3.0'}
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-number@1.3.1':
|
||||||
|
resolution: {integrity: sha512-JBxhSdihdQLQilCtqISEBw5kM+CNGTXzy5j5hNoZECNUEvBUPkAGNEJAeQPMP5abrYks29aSklnSvSyLObXaNQ==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-number/-/recognizers-text-number-1.3.1.tgz}
|
||||||
|
engines: {node: '>=10.3.0'}
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-sequence@1.3.1':
|
||||||
|
resolution: {integrity: sha512-J7Kg35hpm0NcFHmu69Bb4q7DPDiSpCd8ApUZqNm59itIjrQJHpSdl9HF6JxuQQz0Ftc/li5ZLqSuupJAmA/sgg==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-sequence/-/recognizers-text-sequence-1.3.1.tgz}
|
||||||
|
engines: {node: '>=10.3.0'}
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-suite@1.3.0':
|
||||||
|
resolution: {integrity: sha512-uqG4vzy5N2CmBaeINny0bLdnGp0jDbT1moNoLC+Yim3G8kHOU9lpDfwA6VN6HTYaDM5854SNMEzLjJdS1TPFTw==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text-suite/-/recognizers-text-suite-1.3.0.tgz}
|
||||||
|
engines: {node: '>=10.3.0'}
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text@1.3.1':
|
||||||
|
resolution: {integrity: sha512-HikLoRUgSzM4OKP3JVBzUUp3Q7L4wgI17p/3rERF01HVmopcujY3i6wgx8PenCwbenyTNxjr1AwSDSVuFlYedQ==, tarball: https://registry.npmjs.org/@microsoft/recognizers-text/-/recognizers-text-1.3.1.tgz}
|
||||||
|
engines: {node: '>=10.3.0'}
|
||||||
|
|
||||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==, tarball: https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz}
|
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==, tarball: https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz}
|
||||||
engines: {node: ^22.20 || ^24.12 || >=25}
|
engines: {node: ^22.20 || ^24.12 || >=25}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@nlpjs/builtin-duckling@4.26.1':
|
||||||
|
resolution: {integrity: sha512-3qkH955X2g5MXV1EqT3fTAT/lLEdiqqe5IgBDyr+MQB7FOV9R3YhqGIn3DFOl+TSm/tP5n/BAEptkTNn/TOpmQ==, tarball: https://registry.npmjs.org/@nlpjs/builtin-duckling/-/builtin-duckling-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/builtin-microsoft@4.26.1':
|
||||||
|
resolution: {integrity: sha512-AODgzTcfYUf5Ozm00aQnHImDum7Idtl0F9dSPoaXpfj7rZqP8hPZ7iWwdGTAvISH/da2YhjPOU65QSYk2YpjFA==, tarball: https://registry.npmjs.org/@nlpjs/builtin-microsoft/-/builtin-microsoft-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/core-loader@4.26.1':
|
||||||
|
resolution: {integrity: sha512-IiRtn65bdiUSQHy2kusco2fmhk39u2Mc2c5Fsm9+9EVG6BtJCmVEFU/btAzGDAmxEA/E4qKecaAT4LvcW6TPbA==, tarball: https://registry.npmjs.org/@nlpjs/core-loader/-/core-loader-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/core@4.26.1':
|
||||||
|
resolution: {integrity: sha512-M/PeFddsi3y7Z1piFJxsLGm5/xdMhcrpOsml7s6CTEgYo8iduaT30HDd61tZxDyvvJseU6uFqlXSn7XKkAcC1g==, tarball: https://registry.npmjs.org/@nlpjs/core/-/core-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/emoji@4.26.1':
|
||||||
|
resolution: {integrity: sha512-Q0PoXwIvaB1bnRXK4U/YD7mrqaz29Yfed3s2au0iXl1bffUgoG+hs4GORCvyy7DFCCLlc9d5yDM3oLIX/ggZ+Q==, tarball: https://registry.npmjs.org/@nlpjs/emoji/-/emoji-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/evaluator@4.26.1':
|
||||||
|
resolution: {integrity: sha512-WeUrC8qq7+V8Jhkkjc2yiXdzy9V0wbETv8/qasQmL0QmEuwBDJF+fvfl4z2vWpBb0vW07A8aNrFElKELzbpkdg==, tarball: https://registry.npmjs.org/@nlpjs/evaluator/-/evaluator-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-all@4.26.1':
|
||||||
|
resolution: {integrity: sha512-UzRm1JRRAyQqilEOxQ2ySMOitKbhPk5iKYbjD8FREDcPjreUvDxVuQsYUOvYucmEyFcZU2U/TdJx+fX9/bcaKQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-all/-/lang-all-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ar@4.26.1':
|
||||||
|
resolution: {integrity: sha512-MUlVtabt9ltG7WyzCQpFJymLJlnEqp3mxhgN9JHyFH7oZMK3REvMovFfvEUAbfiYrJEv/BN5KKLL7yrvUeaHtg==, tarball: https://registry.npmjs.org/@nlpjs/lang-ar/-/lang-ar-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-bn@4.26.1':
|
||||||
|
resolution: {integrity: sha512-sim1iZKBDdehi/yBUKrLW51QvS9uB+sXW7lj+THVqBy5UsnEQvt4gzE0NsC873uJMh66vt2AlHkhzgPH0qH/nQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-bn/-/lang-bn-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ca@4.26.1':
|
||||||
|
resolution: {integrity: sha512-fD4R5tcAB0uYtNxSEF20b1KmF6nUQSbiJqrIUJI5yis4ObjCYRQnSh4bjVDKUKxyONjbD6L8EaK5GrY1/jkwFQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-ca/-/lang-ca-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-cs@4.26.1':
|
||||||
|
resolution: {integrity: sha512-CqI6VB8toaJ/MlP1D4K9BctA6GpZJhMKyEy+OX9xavDe4r4ao/SxlSaIYK3izK0k+J38lJWC5lXYGazfCdTGjA==, tarball: https://registry.npmjs.org/@nlpjs/lang-cs/-/lang-cs-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-da@4.26.1':
|
||||||
|
resolution: {integrity: sha512-krI/ojeDSi329ENM/hLIsbUh1x4XRTKAbtPcbFxAY6XVhcSVoWPO7L77jFTL1NQeE1oGRFzGHaeC9hZJ8phVbA==, tarball: https://registry.npmjs.org/@nlpjs/lang-da/-/lang-da-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-de@4.26.1':
|
||||||
|
resolution: {integrity: sha512-HfZQwsE5FICq9taVZDiyktmdAePVF5948NM80et0d9mx43RWDFhHKQYgtJPwfQXtdCoQtOM5TOJ2FanGwzPeaA==, tarball: https://registry.npmjs.org/@nlpjs/lang-de/-/lang-de-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-el@4.26.1':
|
||||||
|
resolution: {integrity: sha512-pcOvuSwPCXxI+2xNZZzM4V5pTRDntYoJi0SP/ic2nV4IPQ0nU2j16dYfg1HlvET/E6iN1VTqghrCaf10SMkDGA==, tarball: https://registry.npmjs.org/@nlpjs/lang-el/-/lang-el-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-en-min@4.26.1':
|
||||||
|
resolution: {integrity: sha512-1sJZ7dy7ysqzbsB8IklguvB88J8EPIv4XGVkZCcwecKtOw+fp5LAsZ3TJVmEf18iK1gD4cEGr7qZg5fpPxTpWQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-en-min/-/lang-en-min-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-en@4.26.1':
|
||||||
|
resolution: {integrity: sha512-GVoJpOjyk5TtBAqo/fxsiuuH7jXycyakGT0gw5f01u9lOmUnpJegvXyGff/Nb0j14pXcGHXOhmpWrcTrG2B0LQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-en/-/lang-en-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-es@4.26.1':
|
||||||
|
resolution: {integrity: sha512-fIPQt+WPcNdyxZOCMkOPlMb4Y1iE585QxjB9IAdFz8ZtVg7mc4dlv5f46ud7ppdMh84iLOuOdo6pzu2Cqm14lw==, tarball: https://registry.npmjs.org/@nlpjs/lang-es/-/lang-es-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-eu@4.26.1':
|
||||||
|
resolution: {integrity: sha512-Ha8GHTbgQYd7dwHM8aWHDyxmbUNUcyu/5xlBKqqBOPxysDyZ6Ad0tvj0FmJBy6mYhqmFTPBnEAo69cfuFSqWIQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-eu/-/lang-eu-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-fa@4.26.1':
|
||||||
|
resolution: {integrity: sha512-qJCmNXgJZnfNXUnKnxvEGEzSFBdQT4XU7/rMxuFmSJqmQY7fH/Vsmi5CKF94VRBPOIV4ULlEJuLpUWHXRmOnVQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-fa/-/lang-fa-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-fi@4.26.1':
|
||||||
|
resolution: {integrity: sha512-W/rUcrzSh3KE07q2vOsssTpU1sbX32gbBzKPZfRJ2ZUF4afO+eHxmAywikXubP4kiU3JxVNLvXXEjuGD3SBUbA==, tarball: https://registry.npmjs.org/@nlpjs/lang-fi/-/lang-fi-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-fr@4.26.1':
|
||||||
|
resolution: {integrity: sha512-LTA852atCJnHtKDmtjx/ui5AnvEIkrPx+MJQ2mB3gn8ko6i2UITnJgPmJE9Kej5bLasVZOAJvU/SrfXEmnPGOw==, tarball: https://registry.npmjs.org/@nlpjs/lang-fr/-/lang-fr-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ga@4.26.1':
|
||||||
|
resolution: {integrity: sha512-JsP1CZ8r3Jd6o/Az7cN3exz0HDP3FNYLzh4Vi6ksEkdKF0yCjJ9G5dXZYqS9qFIN5ffemWn29G4WRELY6QH/cQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-ga/-/lang-ga-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-gl@4.26.1':
|
||||||
|
resolution: {integrity: sha512-y1NNu6NVy/6o5UNfihgg0WkSlVr4IvKA5W193CpRLZWS4FccQDmnFFhyYWRkshyDbgEsfsZ0Rs3BoE82+T2Ubg==, tarball: https://registry.npmjs.org/@nlpjs/lang-gl/-/lang-gl-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-hi@4.26.1':
|
||||||
|
resolution: {integrity: sha512-Fw9rXqF5l8q9etJG5uOlEFpnMVjQEWMaCIgQfEcA1yTvieSV8mpoSvQkEZl+DFhww+azareoJ7ZCkx0gJ9UDuQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-hi/-/lang-hi-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-hu@4.26.1':
|
||||||
|
resolution: {integrity: sha512-7dPUn5/ZpLZmsdRwO+dtORuMIiIpnsWbgSLIKdOLh8irhgUR+M2bYTfkdnKcrEcHzHPP8Svn7pU0xk7OKSUA1w==, tarball: https://registry.npmjs.org/@nlpjs/lang-hu/-/lang-hu-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-hy@4.26.1':
|
||||||
|
resolution: {integrity: sha512-T2brpLGDJryAwWmjtnmY8Ot6ZUkCz+/nRR9/QM1PybvZIqOVLjJqA49bqjJfT5DMN89HbwC7I/15NTT0y09i1Q==, tarball: https://registry.npmjs.org/@nlpjs/lang-hy/-/lang-hy-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-id@4.26.1':
|
||||||
|
resolution: {integrity: sha512-rVuIkYFKdltFhMT/a2ZxD9ovoZSVZF7OPuqYjTXW9xKd3Ff32yUrzcf/pHXlqmZOSltqOH3E5jZRRDkHvgUOjQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-id/-/lang-id-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-it@4.26.1':
|
||||||
|
resolution: {integrity: sha512-BZA3QnfQGW91gYaybRmHnCAPBvQggtmHZJrAmuBZUKUS12HoQm8uybjw2fZO+vahEeUQceKNDISRcT1eLLijog==, tarball: https://registry.npmjs.org/@nlpjs/lang-it/-/lang-it-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ja@4.26.1':
|
||||||
|
resolution: {integrity: sha512-QgkuJOkHguRFyfnckH2It5/Kg8zecnOMJsHxYeuDC4tBF7jL/5xqWis+679lYLsXtAkrG8+fjVcBbjyopP0KHg==, tarball: https://registry.npmjs.org/@nlpjs/lang-ja/-/lang-ja-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ko@4.26.1':
|
||||||
|
resolution: {integrity: sha512-Q0N8bLJJ829ILWCKH1UQWPSNyuLaEURAXCawkDju4pt33DBLcpqz9IzO9dnqiFc+fjSgVzZ7WMaLT18hXZQ9vg==, tarball: https://registry.npmjs.org/@nlpjs/lang-ko/-/lang-ko-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-lt@4.26.1':
|
||||||
|
resolution: {integrity: sha512-SeYZxRhdCy+ClQNnF/u0MAtcDui/ocdk4NtgNOCuwNTNuzhN3t3rfGeArfBGmZeg1SIeBLUDE9dsTxYCv5AOEg==, tarball: https://registry.npmjs.org/@nlpjs/lang-lt/-/lang-lt-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ms@4.26.1':
|
||||||
|
resolution: {integrity: sha512-KxWBS+tFY2U8z9UrjQIqMM40npGDOskP5DcWhaEE3zuhzf3RTDYjy8sdz34jVd0fBdbPihX133h3bFibg2Cm7w==, tarball: https://registry.npmjs.org/@nlpjs/lang-ms/-/lang-ms-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ne@4.26.1':
|
||||||
|
resolution: {integrity: sha512-K3E2l+0LTESv+dO+ZTIdvNa+zwMJvvnMiFYYkKvJst6lhc8JgvGOsPxGsjJn6PDhI3wyfQu+dg3b+bnVPu4FDA==, tarball: https://registry.npmjs.org/@nlpjs/lang-ne/-/lang-ne-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-nl@4.26.1':
|
||||||
|
resolution: {integrity: sha512-I/mP1RRbUN4BQ+8NXAl2FKaLHbb7f6S8JVjxHQ0sKHT4BgQ3+r0yO+DVcEsHg+vWRiY1Fyzh0gq0PhLVnF6HnA==, tarball: https://registry.npmjs.org/@nlpjs/lang-nl/-/lang-nl-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-no@4.26.1':
|
||||||
|
resolution: {integrity: sha512-a0CLL2c/OCzbg7J7ugyrsAksI96XhkQ3IeBbbx60o5o/9wsFNik6cPWrkpoE5xNtw7gLlAJWabwDiZXkl8Zrcw==, tarball: https://registry.npmjs.org/@nlpjs/lang-no/-/lang-no-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-pl@4.26.1':
|
||||||
|
resolution: {integrity: sha512-nrDXlq+TzQLE5IpXPIlFMzd8OpquvApWsouh6fmLsD9HZLZI4O3w1M4sXXLzE+9Ggu9Cy1m1QJ0/i7XCcv115g==, tarball: https://registry.npmjs.org/@nlpjs/lang-pl/-/lang-pl-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-pt@4.26.1':
|
||||||
|
resolution: {integrity: sha512-p6yZHaJ0e+n0avMHpdDw5PMk4HkKXjPbOMbrlg0dF+VRqChjxfH478Q423rDyzu/4MzDsIYB+p6KzL9AARKXpg==, tarball: https://registry.npmjs.org/@nlpjs/lang-pt/-/lang-pt-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ro@4.26.1':
|
||||||
|
resolution: {integrity: sha512-baUdTA0DWpDR0Tn6fxo+RDN/6gbuINLCARtHwap2UR/HKQWP2XoH/DIvcjZpwUTalr5MQjso31epcdeRRapczA==, tarball: https://registry.npmjs.org/@nlpjs/lang-ro/-/lang-ro-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ru@4.26.1':
|
||||||
|
resolution: {integrity: sha512-NaZ2DAOGxWG2Us9IyIDs3m6vhGpUaUJRVgzzHHyX3LO3xEYjZmtnA0jEpBaTOe2PuNHThv0WCZUNn9BSurV3PA==, tarball: https://registry.npmjs.org/@nlpjs/lang-ru/-/lang-ru-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-sl@4.26.1':
|
||||||
|
resolution: {integrity: sha512-QBJwcJt+oKUpAnHKNJkLkx9Xm1n4dUPC5GPYfAXTnJZf0hNWJSY21GicdWi7Vu/qFJ3ghIqtSP8D7KIPLnibNw==, tarball: https://registry.npmjs.org/@nlpjs/lang-sl/-/lang-sl-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-sr@4.26.1':
|
||||||
|
resolution: {integrity: sha512-drH3+UqTW637uLWsnLrcp8jEKUGxV61ZgCBjNkVQNEv1/jbpSg6IqgynSY2JyhtnlV0f870KS0HvSbyo5AD4Ng==, tarball: https://registry.npmjs.org/@nlpjs/lang-sr/-/lang-sr-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-sv@4.26.1':
|
||||||
|
resolution: {integrity: sha512-2axkrYFC02tAlxCWeiEKISbe4dSteciP1CIggO/dZglnnLWgdF+g7kOeYMn7abCfFVSnh5vLqfDkrwnyIqt7Ag==, tarball: https://registry.npmjs.org/@nlpjs/lang-sv/-/lang-sv-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-ta@4.26.1':
|
||||||
|
resolution: {integrity: sha512-keeh+croa1TAirV9Fd3OQMo5IkAlTGNWTNweHbi/htYMX0MKOPYxyqg+VH2bml+57VY2aUj/WYgV/p3ATx9EfQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-ta/-/lang-ta-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-th@4.26.1':
|
||||||
|
resolution: {integrity: sha512-2SWZhrln3rMw8/DsRc9yS5bi3qEdGfw2pq9Uejx/UYED5zvvL6kh9AiCJZT4k0wMBGEwWUV6HxJ0Pq/jOTHogg==, tarball: https://registry.npmjs.org/@nlpjs/lang-th/-/lang-th-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-tl@4.26.1':
|
||||||
|
resolution: {integrity: sha512-AzmLtg28tm0VXCm0Q0EY3OtA3m4oYxaqh4VX6uhB4J+PoEsIkm0py12SJxMNIsh/r98pobCumH8KH9bvHQoCAg==, tarball: https://registry.npmjs.org/@nlpjs/lang-tl/-/lang-tl-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-tr@4.26.1':
|
||||||
|
resolution: {integrity: sha512-p30uuXvE9pZeU/5XkrQfvxRgiAOBmP3EyBFGV/+P05PEogaqbsmmtVCgCnR63yeRvVnGbToPBPjRK3OO1y4AEQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-tr/-/lang-tr-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-uk@4.26.1':
|
||||||
|
resolution: {integrity: sha512-PVEvmlhvl6BL3e/Q4qjMPsnwON3cWEYvDh9dg+Si+sjD2Edu9tajolJKcQ6ZA4I8dXrld5xuXx+DEBH/uB4uWQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-uk/-/lang-uk-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/lang-zh@4.26.1':
|
||||||
|
resolution: {integrity: sha512-kwqeqeEgMAMvucVX9HNE1p6s/2APP23ZsS8Um/lNvtswb4gL5jjYF9kyCvRfqlPBQSWWdRv7wwcnNXOvXYkxcQ==, tarball: https://registry.npmjs.org/@nlpjs/lang-zh/-/lang-zh-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/language-min@4.25.0':
|
||||||
|
resolution: {integrity: sha512-g8jtbDbqtRm+dlD/1Vnb4VWfKbKteApEGVTqIMxYkk6N/HMhvLZ5J2svrxzrB98a/HZ0fb//YBfFgymnz9Oukg==, tarball: https://registry.npmjs.org/@nlpjs/language-min/-/language-min-4.25.0.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/language@4.25.0':
|
||||||
|
resolution: {integrity: sha512-tUF6QENoUQ/E26RYc32IgsttStSF9cNO4ySN+BQECn8VpjukWdwbMw073MlOLXzjfeobxa+3hCVrmPPcW+V3UA==, tarball: https://registry.npmjs.org/@nlpjs/language/-/language-4.25.0.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/ner@4.27.0':
|
||||||
|
resolution: {integrity: sha512-ptwkxriJdmgHSH9TfP10JQ1jviaSl2SupSFGUvTuWkuJhobQd3hbnlSq40V6XYvJNmqh9M9zEab/AKeghxYOTA==, tarball: https://registry.npmjs.org/@nlpjs/ner/-/ner-4.27.0.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/neural@4.25.0':
|
||||||
|
resolution: {integrity: sha512-Oz20denGiBe0DlQsS7lN4TNrATN1nXlHKc/HB6jJPegjVmgJVCugDaHwIGoV7qOWyA6F2fRRwOgD+quNT2gVpg==, tarball: https://registry.npmjs.org/@nlpjs/neural/-/neural-4.25.0.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/nlg@4.26.1':
|
||||||
|
resolution: {integrity: sha512-PCJWiZ7464ChXXUGvjBZIFtoqkC24Oy6X63HgQrSv+63svz22Y5Cmu1MYLk77Nb+4keWv+hKhFJKDkvJoOpBVg==, tarball: https://registry.npmjs.org/@nlpjs/nlg/-/nlg-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/nlp@4.27.0':
|
||||||
|
resolution: {integrity: sha512-q6X7sY6TYVnQRZJKF/6mfLFlNA5oRYLhgQ5k3i1IBqH9lbWTAZJr31w/dCf97HXaYaj+vJp3h0ucfNumme9EIw==, tarball: https://registry.npmjs.org/@nlpjs/nlp/-/nlp-4.27.0.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/nlu@4.27.0':
|
||||||
|
resolution: {integrity: sha512-j4DUdoXS/y/Xag6ysYXx7Ve8NBmUVViUSCJhj3r49+zGyYtyVAHuVcqSej5q0tJjn0JSMT+6+ip8klON1q8ixw==, tarball: https://registry.npmjs.org/@nlpjs/nlu/-/nlu-4.27.0.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/request@4.25.0':
|
||||||
|
resolution: {integrity: sha512-MPVYWfFZY03WyFL7GWkUkv8tw968OXsdxFSJEvjXHzhiCe/vAlPCWbvoR+VnoQTgzLHxs/KIF6sIF2s9AzsLmQ==, tarball: https://registry.npmjs.org/@nlpjs/request/-/request-4.25.0.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/sentiment@4.26.1':
|
||||||
|
resolution: {integrity: sha512-U2WmcW3w6yDDO45+Y7v5e6DPQj8e0x+RUUePPyRu2uIZmUtIKG+qCPMWnNLMmYQZoSQEFxmMMlLcGDC7tN7o3w==, tarball: https://registry.npmjs.org/@nlpjs/sentiment/-/sentiment-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/similarity@4.26.1':
|
||||||
|
resolution: {integrity: sha512-QutSBFGo/huNuz60PgqCjub0oBd9S8MLrjme33U5GzxuSvToQzXtn9/ynIia8qDm009D09VXV+LPeNE4h7yuSg==, tarball: https://registry.npmjs.org/@nlpjs/similarity/-/similarity-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/slot@4.26.1':
|
||||||
|
resolution: {integrity: sha512-mK8EEy5O+mRGne822PIKMxHSFh8j+iC7hGJ6T31XdFsNhFEYXLI/0dmeBstZgTSKBTe27HNFgCCwuGb77u0o9w==, tarball: https://registry.npmjs.org/@nlpjs/slot/-/slot-4.26.1.tgz}
|
||||||
|
|
||||||
|
'@nlpjs/xtables@4.25.0':
|
||||||
|
resolution: {integrity: sha512-+baCtMZIp+aDqODLQs8Wyyke5qUqQkL8AGWsZzwYuJV8S7xdW2+XklRnHnkFc3p3foC248TkzG5L8j9r6INOtg==, tarball: https://registry.npmjs.org/@nlpjs/xtables/-/xtables-4.25.0.tgz}
|
||||||
|
|
||||||
'@noble/hashes@1.8.0':
|
'@noble/hashes@1.8.0':
|
||||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==, tarball: https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz}
|
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==, tarball: https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz}
|
||||||
engines: {node: ^14.21.3 || >=16}
|
engines: {node: ^14.21.3 || >=16}
|
||||||
|
|
@ -1118,6 +1333,10 @@ packages:
|
||||||
resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==, tarball: https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-3.0.0.tgz}
|
resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==, tarball: https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-3.0.0.tgz}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
'@tootallnate/once@2.0.1':
|
||||||
|
resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==, tarball: https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==, tarball: https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz}
|
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==, tarball: https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz}
|
||||||
|
|
||||||
|
|
@ -1287,6 +1506,10 @@ packages:
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
adler-32@1.3.1:
|
||||||
|
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==, tarball: https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
agent-base@6.0.2:
|
agent-base@6.0.2:
|
||||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==, tarball: https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz}
|
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==, tarball: https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz}
|
||||||
engines: {node: '>= 6.0.0'}
|
engines: {node: '>= 6.0.0'}
|
||||||
|
|
@ -1390,6 +1613,9 @@ packages:
|
||||||
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==, tarball: https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz}
|
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==, tarball: https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
async@2.6.4:
|
||||||
|
resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==, tarball: https://registry.npmjs.org/async/-/async-2.6.4.tgz}
|
||||||
|
|
||||||
async@3.2.6:
|
async@3.2.6:
|
||||||
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==, tarball: https://registry.npmjs.org/async/-/async-3.2.6.tgz}
|
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==, tarball: https://registry.npmjs.org/async/-/async-3.2.6.tgz}
|
||||||
|
|
||||||
|
|
@ -1431,6 +1657,9 @@ packages:
|
||||||
bcrypt-pbkdf@1.0.2:
|
bcrypt-pbkdf@1.0.2:
|
||||||
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==, tarball: https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz}
|
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==, tarball: https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz}
|
||||||
|
|
||||||
|
bignumber.js@7.2.1:
|
||||||
|
resolution: {integrity: sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ==, tarball: https://registry.npmjs.org/bignumber.js/-/bignumber.js-7.2.1.tgz}
|
||||||
|
|
||||||
binary-extensions@2.3.0:
|
binary-extensions@2.3.0:
|
||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==, tarball: https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==, tarball: https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
@ -1519,6 +1748,10 @@ packages:
|
||||||
caseless@0.12.0:
|
caseless@0.12.0:
|
||||||
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==, tarball: https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz}
|
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==, tarball: https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz}
|
||||||
|
|
||||||
|
cfb@1.2.2:
|
||||||
|
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==, tarball: https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
chai@5.3.3:
|
chai@5.3.3:
|
||||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==, tarball: https://registry.npmjs.org/chai/-/chai-5.3.3.tgz}
|
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==, tarball: https://registry.npmjs.org/chai/-/chai-5.3.3.tgz}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -1589,6 +1822,10 @@ packages:
|
||||||
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==, tarball: https://registry.npmjs.org/clone/-/clone-1.0.4.tgz}
|
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==, tarball: https://registry.npmjs.org/clone/-/clone-1.0.4.tgz}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
|
codepage@1.15.0:
|
||||||
|
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==, tarball: https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, tarball: https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, tarball: https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
|
|
@ -1703,6 +1940,11 @@ packages:
|
||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
crc-32@1.2.2:
|
||||||
|
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==, tarball: https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
cross-env@10.1.0:
|
cross-env@10.1.0:
|
||||||
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==, tarball: https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz}
|
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==, tarball: https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
@ -1890,6 +2132,9 @@ packages:
|
||||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==, tarball: https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz}
|
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==, tarball: https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
doublearray@0.0.2:
|
||||||
|
resolution: {integrity: sha512-aw55FtZzT6AmiamEj2kvmR6BuFqvYgKZUkfQ7teqVRNqD5UE0rw8IeW/3gieHNKQ5sPuDKlljWEn4bzv5+1bHw==, tarball: https://registry.npmjs.org/doublearray/-/doublearray-0.0.2.tgz}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, tarball: https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, tarball: https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -2156,6 +2401,10 @@ packages:
|
||||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==, tarball: https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz}
|
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==, tarball: https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
frac@1.1.2:
|
||||||
|
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==, tarball: https://registry.npmjs.org/frac/-/frac-1.1.2.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
fresh@0.5.2:
|
fresh@0.5.2:
|
||||||
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==, tarball: https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz}
|
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==, tarball: https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
@ -2261,6 +2510,9 @@ packages:
|
||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, tarball: https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, tarball: https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz}
|
||||||
|
|
||||||
|
grapheme-splitter@1.0.4:
|
||||||
|
resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==, tarball: https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz}
|
||||||
|
|
||||||
has-ansi@4.0.1:
|
has-ansi@4.0.1:
|
||||||
resolution: {integrity: sha512-Qr4RtTm30xvEdqUXbSBVWDu+PrTokJOwe/FU+VdfJPk+MXAPoeOzKpRyrDTnZIJwAkQ4oBLTU53nu0HrkF/Z2A==, tarball: https://registry.npmjs.org/has-ansi/-/has-ansi-4.0.1.tgz}
|
resolution: {integrity: sha512-Qr4RtTm30xvEdqUXbSBVWDu+PrTokJOwe/FU+VdfJPk+MXAPoeOzKpRyrDTnZIJwAkQ4oBLTU53nu0HrkF/Z2A==, tarball: https://registry.npmjs.org/has-ansi/-/has-ansi-4.0.1.tgz}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
@ -2306,6 +2558,10 @@ packages:
|
||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
http-proxy-agent@5.0.0:
|
||||||
|
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==, tarball: https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
http-signature@1.4.0:
|
http-signature@1.4.0:
|
||||||
resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==, tarball: https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz}
|
resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==, tarball: https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
|
|
@ -2558,6 +2814,9 @@ packages:
|
||||||
knuth-shuffle-seeded@1.0.6:
|
knuth-shuffle-seeded@1.0.6:
|
||||||
resolution: {integrity: sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==, tarball: https://registry.npmjs.org/knuth-shuffle-seeded/-/knuth-shuffle-seeded-1.0.6.tgz}
|
resolution: {integrity: sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==, tarball: https://registry.npmjs.org/knuth-shuffle-seeded/-/knuth-shuffle-seeded-1.0.6.tgz}
|
||||||
|
|
||||||
|
kuromoji@0.1.2:
|
||||||
|
resolution: {integrity: sha512-V0dUf+C2LpcPEXhoHLMAop/bOht16Dyr+mDiIE39yX3vqau7p80De/koFqpiTcL1zzdZlc3xuHZ8u5gjYRfFaQ==, tarball: https://registry.npmjs.org/kuromoji/-/kuromoji-0.1.2.tgz}
|
||||||
|
|
||||||
lazy-ass@1.6.0:
|
lazy-ass@1.6.0:
|
||||||
resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==, tarball: https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz}
|
resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==, tarball: https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz}
|
||||||
engines: {node: '> 0.8'}
|
engines: {node: '> 0.8'}
|
||||||
|
|
@ -2822,6 +3081,9 @@ packages:
|
||||||
node-html-parser@5.3.3:
|
node-html-parser@5.3.3:
|
||||||
resolution: {integrity: sha512-ncg1033CaX9UexbyA7e1N0aAoAYRDiV8jkTvzEnfd1GDvzFdrsXLzR4p4ik8mwLgnaKP/jyUFWDy9q3jvRT2Jw==, tarball: https://registry.npmjs.org/node-html-parser/-/node-html-parser-5.3.3.tgz}
|
resolution: {integrity: sha512-ncg1033CaX9UexbyA7e1N0aAoAYRDiV8jkTvzEnfd1GDvzFdrsXLzR4p4ik8mwLgnaKP/jyUFWDy9q3jvRT2Jw==, tarball: https://registry.npmjs.org/node-html-parser/-/node-html-parser-5.3.3.tgz}
|
||||||
|
|
||||||
|
node-nlp@4.27.0:
|
||||||
|
resolution: {integrity: sha512-LnkhOUPXX0CMFbSzJ1gHI+7Yb3ULLip5gRsqedXb6pryjcRCbNzPgHXcH/6G9B1vSbDfO+y3X2B4QZpfP12OyQ==, tarball: https://registry.npmjs.org/node-nlp/-/node-nlp-4.27.0.tgz}
|
||||||
|
|
||||||
node-releases@2.0.53:
|
node-releases@2.0.53:
|
||||||
resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==, tarball: https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz}
|
resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==, tarball: https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -3371,6 +3633,10 @@ packages:
|
||||||
split@1.0.1:
|
split@1.0.1:
|
||||||
resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==, tarball: https://registry.npmjs.org/split/-/split-1.0.1.tgz}
|
resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==, tarball: https://registry.npmjs.org/split/-/split-1.0.1.tgz}
|
||||||
|
|
||||||
|
ssf@0.11.2:
|
||||||
|
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==, tarball: https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
sshpk@1.18.0:
|
sshpk@1.18.0:
|
||||||
resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==, tarball: https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz}
|
resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==, tarball: https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -3711,6 +3977,14 @@ packages:
|
||||||
wide-align@1.1.5:
|
wide-align@1.1.5:
|
||||||
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==, tarball: https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz}
|
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==, tarball: https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz}
|
||||||
|
|
||||||
|
wmf@1.0.2:
|
||||||
|
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==, tarball: https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
|
word@0.3.0:
|
||||||
|
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==, tarball: https://registry.npmjs.org/word/-/word-0.3.0.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
workerpool@6.5.1:
|
workerpool@6.5.1:
|
||||||
resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==, tarball: https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz}
|
resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==, tarball: https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz}
|
||||||
|
|
||||||
|
|
@ -3732,6 +4006,11 @@ packages:
|
||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, tarball: https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, tarball: https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz}
|
||||||
|
|
||||||
|
xlsx@0.18.5:
|
||||||
|
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==, tarball: https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
xmlbuilder@15.1.1:
|
xmlbuilder@15.1.1:
|
||||||
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==, tarball: https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz}
|
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==, tarball: https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz}
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
|
|
@ -3785,6 +4064,9 @@ packages:
|
||||||
yup@1.6.1:
|
yup@1.6.1:
|
||||||
resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==, tarball: https://registry.npmjs.org/yup/-/yup-1.6.1.tgz}
|
resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==, tarball: https://registry.npmjs.org/yup/-/yup-1.6.1.tgz}
|
||||||
|
|
||||||
|
zlibjs@0.3.1:
|
||||||
|
resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==, tarball: https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz}
|
||||||
|
|
||||||
zod@3.25.76:
|
zod@3.25.76:
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, tarball: https://registry.npmjs.org/zod/-/zod-3.25.76.tgz}
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, tarball: https://registry.npmjs.org/zod/-/zod-3.25.76.tgz}
|
||||||
|
|
||||||
|
|
@ -4393,9 +4675,344 @@ snapshots:
|
||||||
- encoding
|
- encoding
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-choice@1.3.1':
|
||||||
|
dependencies:
|
||||||
|
'@microsoft/recognizers-text': 1.3.1
|
||||||
|
grapheme-splitter: 1.0.4
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-data-types-timex-expression@1.3.1': {}
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-date-time@1.3.2':
|
||||||
|
dependencies:
|
||||||
|
'@microsoft/recognizers-text': 1.3.1
|
||||||
|
'@microsoft/recognizers-text-number': 1.3.1
|
||||||
|
'@microsoft/recognizers-text-number-with-unit': 1.3.1
|
||||||
|
lodash: 4.18.1
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-number-with-unit@1.3.1':
|
||||||
|
dependencies:
|
||||||
|
'@microsoft/recognizers-text': 1.3.1
|
||||||
|
'@microsoft/recognizers-text-number': 1.3.1
|
||||||
|
lodash: 4.18.1
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-number@1.3.1':
|
||||||
|
dependencies:
|
||||||
|
'@microsoft/recognizers-text': 1.3.1
|
||||||
|
bignumber.js: 7.2.1
|
||||||
|
lodash: 4.18.1
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-sequence@1.3.1':
|
||||||
|
dependencies:
|
||||||
|
'@microsoft/recognizers-text': 1.3.1
|
||||||
|
grapheme-splitter: 1.0.4
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text-suite@1.3.0':
|
||||||
|
dependencies:
|
||||||
|
'@microsoft/recognizers-text': 1.3.1
|
||||||
|
'@microsoft/recognizers-text-choice': 1.3.1
|
||||||
|
'@microsoft/recognizers-text-data-types-timex-expression': 1.3.1
|
||||||
|
'@microsoft/recognizers-text-date-time': 1.3.2
|
||||||
|
'@microsoft/recognizers-text-number': 1.3.1
|
||||||
|
'@microsoft/recognizers-text-number-with-unit': 1.3.1
|
||||||
|
'@microsoft/recognizers-text-sequence': 1.3.1
|
||||||
|
|
||||||
|
'@microsoft/recognizers-text@1.3.1': {}
|
||||||
|
|
||||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@nlpjs/builtin-duckling@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/builtin-microsoft@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@microsoft/recognizers-text-suite': 1.3.0
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/core-loader@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
'@nlpjs/request': 4.25.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@nlpjs/core@4.26.1': {}
|
||||||
|
|
||||||
|
'@nlpjs/emoji@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/evaluator@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
escodegen: 2.1.0
|
||||||
|
esprima: 4.0.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-all@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
'@nlpjs/lang-ar': 4.26.1
|
||||||
|
'@nlpjs/lang-bn': 4.26.1
|
||||||
|
'@nlpjs/lang-ca': 4.26.1
|
||||||
|
'@nlpjs/lang-cs': 4.26.1
|
||||||
|
'@nlpjs/lang-da': 4.26.1
|
||||||
|
'@nlpjs/lang-de': 4.26.1
|
||||||
|
'@nlpjs/lang-el': 4.26.1
|
||||||
|
'@nlpjs/lang-en': 4.26.1
|
||||||
|
'@nlpjs/lang-es': 4.26.1
|
||||||
|
'@nlpjs/lang-eu': 4.26.1
|
||||||
|
'@nlpjs/lang-fa': 4.26.1
|
||||||
|
'@nlpjs/lang-fi': 4.26.1
|
||||||
|
'@nlpjs/lang-fr': 4.26.1
|
||||||
|
'@nlpjs/lang-ga': 4.26.1
|
||||||
|
'@nlpjs/lang-gl': 4.26.1
|
||||||
|
'@nlpjs/lang-hi': 4.26.1
|
||||||
|
'@nlpjs/lang-hu': 4.26.1
|
||||||
|
'@nlpjs/lang-hy': 4.26.1
|
||||||
|
'@nlpjs/lang-id': 4.26.1
|
||||||
|
'@nlpjs/lang-it': 4.26.1
|
||||||
|
'@nlpjs/lang-ja': 4.26.1
|
||||||
|
'@nlpjs/lang-ko': 4.26.1
|
||||||
|
'@nlpjs/lang-lt': 4.26.1
|
||||||
|
'@nlpjs/lang-ms': 4.26.1
|
||||||
|
'@nlpjs/lang-ne': 4.26.1
|
||||||
|
'@nlpjs/lang-nl': 4.26.1
|
||||||
|
'@nlpjs/lang-no': 4.26.1
|
||||||
|
'@nlpjs/lang-pl': 4.26.1
|
||||||
|
'@nlpjs/lang-pt': 4.26.1
|
||||||
|
'@nlpjs/lang-ro': 4.26.1
|
||||||
|
'@nlpjs/lang-ru': 4.26.1
|
||||||
|
'@nlpjs/lang-sl': 4.26.1
|
||||||
|
'@nlpjs/lang-sr': 4.26.1
|
||||||
|
'@nlpjs/lang-sv': 4.26.1
|
||||||
|
'@nlpjs/lang-ta': 4.26.1
|
||||||
|
'@nlpjs/lang-th': 4.26.1
|
||||||
|
'@nlpjs/lang-tl': 4.26.1
|
||||||
|
'@nlpjs/lang-tr': 4.26.1
|
||||||
|
'@nlpjs/lang-uk': 4.26.1
|
||||||
|
'@nlpjs/lang-zh': 4.26.1
|
||||||
|
'@nlpjs/language': 4.25.0
|
||||||
|
|
||||||
|
'@nlpjs/lang-ar@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-bn@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-ca@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-cs@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-da@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-de@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-el@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-en-min@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-en@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
'@nlpjs/lang-en-min': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-es@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-eu@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-fa@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-fi@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-fr@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-ga@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-gl@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-hi@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-hu@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-hy@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-id@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-it@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-ja@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
kuromoji: 0.1.2
|
||||||
|
|
||||||
|
'@nlpjs/lang-ko@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-lt@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-ms@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
'@nlpjs/lang-id': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-ne@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-nl@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-no@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-pl@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-pt@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-ro@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-ru@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-sl@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-sr@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-sv@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-ta@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-th@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-tl@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-tr@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-uk@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/lang-zh@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/language-min@4.25.0': {}
|
||||||
|
|
||||||
|
'@nlpjs/language@4.25.0': {}
|
||||||
|
|
||||||
|
'@nlpjs/ner@4.27.0':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
'@nlpjs/language-min': 4.25.0
|
||||||
|
'@nlpjs/similarity': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/neural@4.25.0': {}
|
||||||
|
|
||||||
|
'@nlpjs/nlg@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/nlp@4.27.0':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
'@nlpjs/ner': 4.27.0
|
||||||
|
'@nlpjs/nlg': 4.26.1
|
||||||
|
'@nlpjs/nlu': 4.27.0
|
||||||
|
'@nlpjs/sentiment': 4.26.1
|
||||||
|
'@nlpjs/slot': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/nlu@4.27.0':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
'@nlpjs/language-min': 4.25.0
|
||||||
|
'@nlpjs/neural': 4.25.0
|
||||||
|
'@nlpjs/similarity': 4.26.1
|
||||||
|
|
||||||
|
'@nlpjs/request@4.25.0':
|
||||||
|
dependencies:
|
||||||
|
http-proxy-agent: 5.0.0
|
||||||
|
https-proxy-agent: 5.0.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@nlpjs/sentiment@4.26.1':
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/core': 4.26.1
|
||||||
|
'@nlpjs/language-min': 4.25.0
|
||||||
|
'@nlpjs/neural': 4.25.0
|
||||||
|
|
||||||
|
'@nlpjs/similarity@4.26.1': {}
|
||||||
|
|
||||||
|
'@nlpjs/slot@4.26.1': {}
|
||||||
|
|
||||||
|
'@nlpjs/xtables@4.25.0':
|
||||||
|
dependencies:
|
||||||
|
xlsx: 0.18.5
|
||||||
|
|
||||||
'@noble/hashes@1.8.0': {}
|
'@noble/hashes@1.8.0': {}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
|
|
@ -4582,6 +5199,8 @@ snapshots:
|
||||||
|
|
||||||
'@teppeis/multimaps@3.0.0': {}
|
'@teppeis/multimaps@3.0.0': {}
|
||||||
|
|
||||||
|
'@tootallnate/once@2.0.1': {}
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.29.8
|
'@babel/parser': 7.29.8
|
||||||
|
|
@ -4804,6 +5423,8 @@ snapshots:
|
||||||
|
|
||||||
acorn@8.18.0: {}
|
acorn@8.18.0: {}
|
||||||
|
|
||||||
|
adler-32@1.3.1: {}
|
||||||
|
|
||||||
agent-base@6.0.2:
|
agent-base@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
|
|
@ -4893,6 +5514,10 @@ snapshots:
|
||||||
|
|
||||||
astral-regex@2.0.0: {}
|
astral-regex@2.0.0: {}
|
||||||
|
|
||||||
|
async@2.6.4:
|
||||||
|
dependencies:
|
||||||
|
lodash: 4.18.1
|
||||||
|
|
||||||
async@3.2.6: {}
|
async@3.2.6: {}
|
||||||
|
|
||||||
asynckit@0.4.0: {}
|
asynckit@0.4.0: {}
|
||||||
|
|
@ -4929,6 +5554,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
tweetnacl: 0.14.5
|
tweetnacl: 0.14.5
|
||||||
|
|
||||||
|
bignumber.js@7.2.1: {}
|
||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
blob-util@2.0.2: {}
|
blob-util@2.0.2: {}
|
||||||
|
|
@ -5027,6 +5654,11 @@ snapshots:
|
||||||
|
|
||||||
caseless@0.12.0: {}
|
caseless@0.12.0: {}
|
||||||
|
|
||||||
|
cfb@1.2.2:
|
||||||
|
dependencies:
|
||||||
|
adler-32: 1.3.1
|
||||||
|
crc-32: 1.2.2
|
||||||
|
|
||||||
chai@5.3.3:
|
chai@5.3.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
assertion-error: 2.0.1
|
assertion-error: 2.0.1
|
||||||
|
|
@ -5106,6 +5738,8 @@ snapshots:
|
||||||
clone@1.0.4:
|
clone@1.0.4:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
codepage@1.15.0: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
|
|
@ -5187,6 +5821,8 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
crc-32@1.2.2: {}
|
||||||
|
|
||||||
cross-env@10.1.0:
|
cross-env@10.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@epic-web/invariant': 1.0.0
|
'@epic-web/invariant': 1.0.0
|
||||||
|
|
@ -5431,6 +6067,8 @@ snapshots:
|
||||||
|
|
||||||
dotenv@16.6.1: {}
|
dotenv@16.6.1: {}
|
||||||
|
|
||||||
|
doublearray@0.0.2: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
|
@ -5823,6 +6461,8 @@ snapshots:
|
||||||
|
|
||||||
forwarded@0.2.0: {}
|
forwarded@0.2.0: {}
|
||||||
|
|
||||||
|
frac@1.1.2: {}
|
||||||
|
|
||||||
fresh@0.5.2: {}
|
fresh@0.5.2: {}
|
||||||
|
|
||||||
from@0.1.7: {}
|
from@0.1.7: {}
|
||||||
|
|
@ -5944,6 +6584,8 @@ snapshots:
|
||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
|
grapheme-splitter@1.0.4: {}
|
||||||
|
|
||||||
has-ansi@4.0.1:
|
has-ansi@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-regex: 4.1.1
|
ansi-regex: 4.1.1
|
||||||
|
|
@ -5984,6 +6626,14 @@ snapshots:
|
||||||
statuses: 2.0.2
|
statuses: 2.0.2
|
||||||
toidentifier: 1.0.1
|
toidentifier: 1.0.1
|
||||||
|
|
||||||
|
http-proxy-agent@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@tootallnate/once': 2.0.1
|
||||||
|
agent-base: 6.0.2
|
||||||
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
http-signature@1.4.0:
|
http-signature@1.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
assert-plus: 1.0.0
|
assert-plus: 1.0.0
|
||||||
|
|
@ -6226,6 +6876,12 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
seed-random: 2.2.0
|
seed-random: 2.2.0
|
||||||
|
|
||||||
|
kuromoji@0.1.2:
|
||||||
|
dependencies:
|
||||||
|
async: 2.6.4
|
||||||
|
doublearray: 0.0.2
|
||||||
|
zlibjs: 0.3.1
|
||||||
|
|
||||||
lazy-ass@1.6.0: {}
|
lazy-ass@1.6.0: {}
|
||||||
|
|
||||||
lazy-ass@2.0.3: {}
|
lazy-ass@2.0.3: {}
|
||||||
|
|
@ -6477,6 +7133,26 @@ snapshots:
|
||||||
css-select: 4.3.0
|
css-select: 4.3.0
|
||||||
he: 1.2.0
|
he: 1.2.0
|
||||||
|
|
||||||
|
node-nlp@4.27.0:
|
||||||
|
dependencies:
|
||||||
|
'@nlpjs/builtin-duckling': 4.26.1
|
||||||
|
'@nlpjs/builtin-microsoft': 4.26.1
|
||||||
|
'@nlpjs/core-loader': 4.26.1
|
||||||
|
'@nlpjs/emoji': 4.26.1
|
||||||
|
'@nlpjs/evaluator': 4.26.1
|
||||||
|
'@nlpjs/lang-all': 4.26.1
|
||||||
|
'@nlpjs/language': 4.25.0
|
||||||
|
'@nlpjs/neural': 4.25.0
|
||||||
|
'@nlpjs/nlg': 4.26.1
|
||||||
|
'@nlpjs/nlp': 4.27.0
|
||||||
|
'@nlpjs/nlu': 4.27.0
|
||||||
|
'@nlpjs/request': 4.25.0
|
||||||
|
'@nlpjs/sentiment': 4.26.1
|
||||||
|
'@nlpjs/similarity': 4.26.1
|
||||||
|
'@nlpjs/xtables': 4.25.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
node-releases@2.0.53: {}
|
node-releases@2.0.53: {}
|
||||||
|
|
||||||
node-source-walk@7.0.2:
|
node-source-walk@7.0.2:
|
||||||
|
|
@ -7075,6 +7751,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
through: 2.3.8
|
through: 2.3.8
|
||||||
|
|
||||||
|
ssf@0.11.2:
|
||||||
|
dependencies:
|
||||||
|
frac: 1.1.2
|
||||||
|
|
||||||
sshpk@1.18.0:
|
sshpk@1.18.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
asn1: 0.2.6
|
asn1: 0.2.6
|
||||||
|
|
@ -7399,6 +8079,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
|
|
||||||
|
wmf@1.0.2: {}
|
||||||
|
|
||||||
|
word@0.3.0: {}
|
||||||
|
|
||||||
workerpool@6.5.1: {}
|
workerpool@6.5.1: {}
|
||||||
|
|
||||||
workerpool@9.3.4: {}
|
workerpool@9.3.4: {}
|
||||||
|
|
@ -7423,6 +8107,16 @@ snapshots:
|
||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
xlsx@0.18.5:
|
||||||
|
dependencies:
|
||||||
|
adler-32: 1.3.1
|
||||||
|
cfb: 1.2.2
|
||||||
|
codepage: 1.15.0
|
||||||
|
crc-32: 1.2.2
|
||||||
|
ssf: 0.11.2
|
||||||
|
wmf: 1.0.2
|
||||||
|
word: 0.3.0
|
||||||
|
|
||||||
xmlbuilder@15.1.1: {}
|
xmlbuilder@15.1.1: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
@ -7480,4 +8174,6 @@ snapshots:
|
||||||
toposort: 2.0.2
|
toposort: 2.0.2
|
||||||
type-fest: 2.19.0
|
type-fest: 2.19.0
|
||||||
|
|
||||||
|
zlibjs@0.3.1: {}
|
||||||
|
|
||||||
zod@3.25.76: {}
|
zod@3.25.76: {}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
# Secret partagé attendu sur le header `X-Intent-Service-Secret` de chaque
|
|
||||||
# requête (sauf `GET /health`) — doit matcher `INTENT_SERVICE_SECRET` côté
|
|
||||||
# apps/api/.env (voir apps/api/src/config/env.ts). Requis, pas de valeur par
|
|
||||||
# défaut : `Settings` (intent_service/config.py) refuse de démarrer sans.
|
|
||||||
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|
||||||
|
|
||||||
# Optionnel — niveau du logging JSON structuré (intent_service/logging_config.py).
|
|
||||||
# INFO par défaut : chaque appel /v1/process et /v1/train journalise son
|
|
||||||
# input (locale/texte, entrées d'entraînement) et son output (entités,
|
|
||||||
# intent, score) à ce niveau.
|
|
||||||
# LOG_LEVEL=INFO
|
|
||||||
5
services/tech-step-intent-service/.gitignore
vendored
5
services/tech-step-intent-service/.gitignore
vendored
|
|
@ -1,5 +0,0 @@
|
||||||
.venv/
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
.pytest_cache/
|
|
||||||
.env
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
# Standalone image for services/tech-step-intent-service — hors du build
|
|
||||||
# apps/api (voir services/tech-step-llm-worker/Dockerfile pour le précédent
|
|
||||||
# direct : un service Python/spaCy n'a rien à faire dans l'image Node de
|
|
||||||
# l'API, et inversement). Rien n'est persisté sur disque (pas de VOLUME,
|
|
||||||
# contrairement au worker LLM) : tout l'état (textcat/matcher entraînés)
|
|
||||||
# vit en mémoire, reconstruit à chaque `/v1/train` depuis un corpus que ce
|
|
||||||
# service ne possède pas lui-même (voir intent_service/README.md).
|
|
||||||
FROM python:3.12-slim AS base
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
|
||||||
WORKDIR /service
|
|
||||||
|
|
||||||
FROM base AS build
|
|
||||||
# `uv.lock` est commité pour ce service (même rigueur que
|
|
||||||
# `pnpm-lock.yaml`/`--frozen-lockfile` pour apps/api et
|
|
||||||
# services/tech-step-llm-worker) — `--frozen` échoue bruyamment si
|
|
||||||
# `pyproject.toml` a dérivé du lock plutôt que de re-résoudre en silence.
|
|
||||||
# `--no-install-project` sépare l'installation des dépendances (dont les
|
|
||||||
# wheels de modèles spaCy, pinnés par URL dans pyproject.toml) de la copie
|
|
||||||
# du code applicatif, pour que le cache de layer Docker survive à un
|
|
||||||
# changement dans intent_service/ sans retélécharger ~80 Mo de modèles.
|
|
||||||
# Chemins préfixés par `services/tech-step-intent-service/` : le contexte
|
|
||||||
# de build est la racine du repo (`docker-compose.yml`'s `build.context: .`),
|
|
||||||
# même convention que `services/tech-step-llm-worker/Dockerfile`.
|
|
||||||
COPY services/tech-step-intent-service/pyproject.toml services/tech-step-intent-service/uv.lock ./
|
|
||||||
RUN uv sync --frozen --no-install-project --no-dev
|
|
||||||
COPY services/tech-step-intent-service/intent_service ./intent_service
|
|
||||||
RUN uv sync --frozen --no-dev
|
|
||||||
|
|
||||||
FROM base AS runtime
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
COPY --from=build /service /service
|
|
||||||
EXPOSE 8000
|
|
||||||
CMD ["uv", "run", "uvicorn", "intent_service.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
# tech-step-intent-service
|
|
||||||
|
|
||||||
Microservice de détection d'intention (technique de cuisine) — remplace le
|
|
||||||
pipeline `node-nlp` qui vivait dans `apps/api`
|
|
||||||
(`TechStepClassifierService`, `apps/api/src/lib/recipe-matching/tech-step-matcher.ts`) :
|
|
||||||
|
|
||||||
1. **NER par phrases** (`spacy.matcher.PhraseMatcher`) — trouve les mentions
|
|
||||||
candidates d'une technique dans un texte, à partir des `synonyms` de
|
|
||||||
chaque technique.
|
|
||||||
2. **Classification d'intention** (`textcat` spaCy, bag-of-words) — verdict
|
|
||||||
de la technique qu'une clause de texte *signifie*, entraîné sur les
|
|
||||||
`utterances` de chaque technique (y compris des paraphrases n'utilisant
|
|
||||||
jamais le mot-clé lui-même).
|
|
||||||
3. **NER par phrases, ustensiles** (`spacy.matcher.PhraseMatcher`, second
|
|
||||||
matcher indépendant) — trouve les mentions d'un ustensile de cuisine
|
|
||||||
(`intent_service/utensil_vocabulary.py`, `UTENSIL_VOCABULARY`), sans
|
|
||||||
`textcat` associé : contrairement à une technique, un ustensile mentionné
|
|
||||||
n'a pas besoin d'être interprété selon le contexte. Renvoyé dans la même
|
|
||||||
liste `entities` que les techniques, discriminé par `kind`.
|
|
||||||
|
|
||||||
Basé sur **spaCy** (`fr_core_news_md`/`en_core_web_md`) plutôt que node-nlp —
|
|
||||||
écosystème NLP plus robuste/maintenu, avec l'ambition à terme (hors scope de
|
|
||||||
ce service en l'état) de pouvoir aussi absorber ce que fait aujourd'hui
|
|
||||||
`services/tech-step-llm-worker` une fois ce pipeline assez riche pour s'en
|
|
||||||
passer (les modèles `md`, avec vecteurs de mots, sont conservés dans ce but,
|
|
||||||
même si rien ici ne s'en sert encore).
|
|
||||||
|
|
||||||
## Ce service est entièrement autonome
|
|
||||||
|
|
||||||
Contrairement à sa toute première version, **ce service possède désormais
|
|
||||||
son propre corpus** — `intent_service/training_data.py`
|
|
||||||
(`TECH_STEP_TRAINING_DATA`), revu par PR comme le reste du code. Il
|
|
||||||
s'entraîne lui-même une seule fois, à son propre démarrage
|
|
||||||
(`PipelineRegistry.initialize()`, appelé par `main.py`'s `lifespan`), et ne
|
|
||||||
persiste jamais rien sur disque — un redémarrage du process réentraîne
|
|
||||||
toujours from scratch depuis ce fichier. `apps/api` ne connaît plus aucune
|
|
||||||
technique ni aucun synonyme : il n'appelle plus que `POST /v1/process` (plus
|
|
||||||
de `POST /v1/train`, supprimé).
|
|
||||||
|
|
||||||
Workflow mainteneur pour changer le corpus :
|
|
||||||
|
|
||||||
1. Éditer `intent_service/training_data.py` à la main (informé par le
|
|
||||||
rapport de `apps/api/src/scripts/list-pending-training-suggestions.ts`)
|
|
||||||
pour une technique, ou `intent_service/utensil_vocabulary.py` pour un
|
|
||||||
ustensile (pas de rapport équivalent pour ce dernier — pas de mécanisme
|
|
||||||
de correction utilisateur sur les ustensiles aujourd'hui). Chaque
|
|
||||||
technique doit garder le même nombre d'`utterances` que les autres, par
|
|
||||||
locale (voir `training_data.py`'s own doc comment) — une technique
|
|
||||||
ajoutée avec moins que le max courant, exécuter `augment_utterances.py`
|
|
||||||
(racine de ce service) pour rééquilibrer, puis **impérativement**
|
|
||||||
relancer l'étape 3 ci-dessous avant de committer : chaque tentative
|
|
||||||
passée d'élargir ce corpus (voir l'historique Git de
|
|
||||||
`training_data.py`) a dû être ajustée ou annulée après coup faute
|
|
||||||
d'avoir vérifié le F1 avant de pousser.
|
|
||||||
2. **Redémarrer ce service** (`docker compose restart tech-step-intent-service`,
|
|
||||||
ou simplement redéployer) — le nouveau corpus n'a d'effet qu'une fois
|
|
||||||
réentraîné au démarrage, contrairement à l'ancienne version qui pouvait
|
|
||||||
être réentraînée à chaud via `POST /v1/train`.
|
|
||||||
3. Depuis `apps/api`, lancer `pnpm --filter api exec tsx
|
|
||||||
src/scripts/retrain-tech-steps.ts` — vérifie le F1 contre
|
|
||||||
`TECH_STEP_EVAL_DATASET` avant de backfiller les recettes existantes.
|
|
||||||
|
|
||||||
## Pourquoi ce service vit hors du workspace pnpm
|
|
||||||
|
|
||||||
Même raisonnement que `services/tech-step-llm-worker` : un service Python
|
|
||||||
n'a rien à faire dans `pnpm-workspace.yaml` (qui ne couvre que
|
|
||||||
`apps/*`/`packages/*`), et ses dépendances (spaCy, ses modèles) ne doivent
|
|
||||||
jamais se retrouver dans l'image `apps/api`. **Aucun accès direct à
|
|
||||||
Postgres** non plus — la résolution `TechStep.key -> id` reste entièrement
|
|
||||||
côté `apps/api` (`TechStepClassifierService`), ce service ne manipule que
|
|
||||||
des `uid` (chaînes opaques) tout du long.
|
|
||||||
|
|
||||||
## Contrat HTTP
|
|
||||||
|
|
||||||
Voir `intent_service/schemas.py` pour le détail exact. En résumé :
|
|
||||||
|
|
||||||
- `GET /health` — sans authentification, `200` une fois ce service
|
|
||||||
entièrement prêt : modèles spaCy de base chargés **et** les deux locales
|
|
||||||
entraînées (pas de lazy-load, voir `intent_service/main.py`) — voir
|
|
||||||
"Temps de démarrage" plus bas pour ce que ça implique en pratique.
|
|
||||||
- `POST /v1/process` — `{ locale, text }` → `{ entities: [{ uid, start, end, kind }], intent, score }`,
|
|
||||||
`kind` valant `"technique"` ou `"utensil"` selon le `PhraseMatcher` qui a
|
|
||||||
trouvé la mention (voir point 3 ci-dessus). `apps/api`'s `tech-step-matcher.ts`
|
|
||||||
filtre par `kind` pour savoir laquelle des deux résoudre (`TechStep`/`Utensil`).
|
|
||||||
|
|
||||||
`/v1/process` exige le header `X-Intent-Service-Secret` (voir
|
|
||||||
`intent_service/security.py`), qui doit matcher `INTENT_SERVICE_SECRET`
|
|
||||||
côté `apps/api`.
|
|
||||||
|
|
||||||
## Temps de démarrage
|
|
||||||
|
|
||||||
**Ce service met plusieurs minutes à devenir `healthy`** — contrairement à
|
|
||||||
node-nlp (entraînement quasi instantané), entraîner le `textcat` sur le
|
|
||||||
corpus réel (~74 techniques, chaque technique entraînée sur ses `synonyms`
|
|
||||||
en plus de ses `utterances` — voir `locale_pipeline.py`) prend de l'ordre
|
|
||||||
de 540 secondes pour `fr` / 390 secondes pour `en` (mesuré localement,
|
|
||||||
sans GPU), donc environ 930 secondes (~15-16 minutes) pour `fr`+`en`
|
|
||||||
combinés à chaque démarrage du process — chaque technique a désormais le
|
|
||||||
même nombre d'`utterances` par locale (voir `training_data.py`'s own doc
|
|
||||||
comment), légèrement plus qu'avant ce rééquilibrage. `docker-compose.yml`
|
|
||||||
et `.github/workflows/ci.yml` ont un `start_period`/timeout d'attente
|
|
||||||
généreux pour ça (`1200s`) — voir leurs propres commentaires. C'est un
|
|
||||||
compromis
|
|
||||||
assumé, pas un défaut de configuration à corriger : moins d'itérations
|
|
||||||
entraîne plus vite mais laisse des verdicts corrects sous
|
|
||||||
`CONFIDENCE_THRESHOLD` (voir le commentaire de cette constante,
|
|
||||||
`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`, et celui de
|
|
||||||
`_TRAINING_ITERATIONS`/`_TRAINING_BATCH_SIZE` dans `locale_pipeline.py`
|
|
||||||
pour le détail du compromis).
|
|
||||||
|
|
||||||
## Logs
|
|
||||||
|
|
||||||
`intent_service/logging_config.py` branche un format JSON structuré (une
|
|
||||||
ligne par évènement — `timestamp`/`level`/`message` + champs métier fusionnés
|
|
||||||
— même convention que `LoggerService` côté `apps/api`) sur toute la
|
|
||||||
journalisation de ce service, niveau `LOG_LEVEL` (`INFO` par défaut, voir
|
|
||||||
`.env.example`). `routes/process.py` journalise chaque appel avec son input
|
|
||||||
et son output complets, `pipeline_registry.py` journalise le déroulement de
|
|
||||||
l'entraînement au démarrage :
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"timestamp": "...", "level": "info", "message": "tech-step NLP process", "locale": "fr", "text": "faire fondre le beurre", "entities": [{"uid": "melt", "start": 6, "end": 13, "kind": "technique"}], "intent": "melt", "score": 0.93}
|
|
||||||
```
|
|
||||||
|
|
||||||
Le chatter interne de spaCy (`"spacy"` logger — chargement de vocabulaire,
|
|
||||||
etc.) est explicitement mis à `WARNING` pour ne pas noyer ces lignes.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
Ce service utilise [`uv`](https://docs.astral.sh/uv/) pour ses dépendances
|
|
||||||
(`uv.lock` committé, `uv sync --frozen` partout — Dockerfile, CI, dev).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd services/tech-step-intent-service
|
|
||||||
uv sync
|
|
||||||
cp .env.example .env
|
|
||||||
# édite .env : génère un INTENT_SERVICE_SECRET, identique à celui d'apps/api
|
|
||||||
uv run uvicorn intent_service.main:app --reload --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
`apps/api` (natif, `pnpm dev:api`, ou sa suite Mocha) doit pointer
|
|
||||||
`INTENT_SERVICE_BASE_URL=http://localhost:8000` et le même
|
|
||||||
`INTENT_SERVICE_SECRET` (voir `apps/api/.env.example`).
|
|
||||||
|
|
||||||
## Running via Docker Compose
|
|
||||||
|
|
||||||
`docker-compose.yml` (racine) définit un service `tech-step-intent-service`
|
|
||||||
aux côtés de `postgres`/`app`/`tech-step-llm-worker` — **pas optionnel**,
|
|
||||||
contrairement au worker LLM : sans lui, `apps/api` ne peut plus détecter
|
|
||||||
aucune technique de cuisine. `app` attend qu'il soit `healthy`
|
|
||||||
(`depends_on: condition: service_healthy`) avant de démarrer — voir "Temps
|
|
||||||
de démarrage" ci-dessus pour combien de temps ça prend en pratique.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run pytest
|
|
||||||
```
|
|
||||||
|
|
||||||
`tests/test_locale_pipeline_entities.py` rejoue les cas d'offsets caractère
|
|
||||||
exacts et d'insensibilité accents/casse de
|
|
||||||
`apps/api/test/recipe-matching/tech-step-matcher.test.ts` — le point de
|
|
||||||
fidélité le plus critique de ce service (voir le plan de migration).
|
|
||||||
`tests/test_utensil_matching.py` couvre le second `PhraseMatcher`
|
|
||||||
(ustensiles) de la même façon, contre le vocabulaire réel (statique, pas
|
|
||||||
besoin d'un jeu de test dédié comme pour les techniques).
|
|
||||||
`tests/conftest.py`'s fixture `client` (scope "session") ne s'entraîne
|
|
||||||
qu'une seule fois pour toute la suite — c'est *le vrai corpus complet*,
|
|
||||||
pas un jeu jouet, donc la première utilisation de cette fixture prend le
|
|
||||||
même temps qu'un vrai démarrage (voir "Temps de démarrage" ci-dessus).
|
|
||||||
|
|
||||||
Aucun test ici ne dépend d'une vraie base Postgres ni d'`apps/api` en
|
|
||||||
service — à l'inverse, la suite Mocha d'`apps/api`
|
|
||||||
(`tech-step-matcher.test.ts`/`recipe-translation.test.ts`) exige elle une
|
|
||||||
vraie instance de ce service tournant (voir `apps/api/.env.test`), conforme
|
|
||||||
à la convention du repo de ne jamais mocker un service interne.
|
|
||||||
|
|
||||||
## Limitations connues
|
|
||||||
|
|
||||||
- **Démarrage lent** (~15-16 minutes) — voir "Temps de démarrage" ci-dessus.
|
|
||||||
Une optimisation possible non explorée : parallélisation de
|
|
||||||
l'entraînement `fr`/`en` (actuellement séquentiel,
|
|
||||||
`PipelineRegistry.initialize`).
|
|
||||||
- **`CONFIDENCE_THRESHOLD` côté `apps/api` est un placeholder** depuis
|
|
||||||
l'élargissement du corpus à ~74 techniques (calibré à la main, pas via
|
|
||||||
une vraie repasse de `calibrate-tech-step-threshold.ts` contre
|
|
||||||
`TECH_STEP_EVAL_DATASET` — voir le commentaire de cette constante).
|
|
||||||
- **Textcat bag-of-words** (`spacy.TextCatBOW.v3`) — suffisant pour le
|
|
||||||
corpus actuel une fois correctement entraîné, mais n'exploite pas les
|
|
||||||
vecteurs de mots des modèles `md` chargés. Migrable vers une architecture
|
|
||||||
tok2vec/similarité sans changer le contrat HTTP, si le F1 mesuré par
|
|
||||||
`apps/api/src/scripts/calibrate-tech-step-threshold.ts` le justifie un
|
|
||||||
jour.
|
|
||||||
- **Reconstruit tout le pipeline à chaque démarrage** (pas de persistance,
|
|
||||||
pas de fusion incrémentale) — un choix délibéré (voir
|
|
||||||
`LocalePipeline.train`), pas une limitation à lever : `training_data.py`
|
|
||||||
doit toujours rester l'unique source de vérité, jamais un état sur disque
|
|
||||||
qui pourrait dériver.
|
|
||||||
|
|
@ -1,282 +0,0 @@
|
||||||
"""Maintainer script — equalizes every technique's `utterances` count
|
|
||||||
(per locale) to the corpus's own current maximum for that locale, never a
|
|
||||||
fixed number picked in the abstract. Preserves every existing utterance,
|
|
||||||
synonym, and comment verbatim; only ever *adds*, never rewrites or removes.
|
|
||||||
|
|
||||||
**Why "equalize to the current max", not "pad everyone to 20"** — this
|
|
||||||
script's own history: three earlier attempts forced every technique up to
|
|
||||||
a flat 20 `utterances`/locale (12-17 new ones per technique on average).
|
|
||||||
All three measurably *failed*
|
|
||||||
`test/recipe-matching/tech-step-eval.test.ts`'s F1 >= 0.8 regression gate
|
|
||||||
(0.7999 -> 0.791 -> 0.744, each attempt worse than the last), regardless of
|
|
||||||
whether the added content was mostly generic modal-frame padding ("il
|
|
||||||
faut ...") or mostly synonym substitution. The common factor across all
|
|
||||||
three wasn't *how* the filler was generated, it was *how much*: this
|
|
||||||
corpus's real per-technique max was only 7 (fr) / 5 (en) before any of
|
|
||||||
this — forcing every technique up to 20 meant most of them tripled or
|
|
||||||
quadrupled in size on synthetic content alone, which measurably hurt
|
|
||||||
inter-class separability more than it helped. Equalizing to the corpus's
|
|
||||||
*own* current max instead means at most a few new utterances per
|
|
||||||
technique (most need 1-4), which is a small enough addition to plausibly
|
|
||||||
preserve the F1 gate while still satisfying "same amount of signal per
|
|
||||||
class" (the actual goal — consistent detection quality across techniques,
|
|
||||||
not a specific round number).
|
|
||||||
|
|
||||||
**Generation strategy** — synonym substitution first (see
|
|
||||||
`_synonym_variants`): for every existing utterance whose leading phrase
|
|
||||||
exactly matches one of the technique's own `synonyms`, swap in every
|
|
||||||
*other* synonym from the same list (e.g. `melt`'s "faire fondre le
|
|
||||||
beurre" -> "liquéfier le beurre") — genuinely technique-distinguishing
|
|
||||||
vocabulary, not filler shared across every class. A technique whose
|
|
||||||
`synonyms` only ever appear *mid-sentence* (the "cut style" techniques —
|
|
||||||
`julienne`, `brunoise`, `mirepoix`, `paysanne`... — e.g. "couper les
|
|
||||||
carottes en julienne" doesn't *start* with any of `julienne`'s own
|
|
||||||
synonyms) has no leading-phrase match to substitute, so a small modal-frame
|
|
||||||
fallback (`_FR_FRAMES`/`_EN_FRAMES`, 2 per locale — much smaller than the
|
|
||||||
12/10 used in the failed 20-target attempts) closes the remainder. Safe at
|
|
||||||
this scale specifically *because* the gap being closed is small (equalizing
|
|
||||||
to the corpus's own current max, 1-4 utterances short per technique, not
|
|
||||||
13-17) — see this module's own doc comment above for why volume, not
|
|
||||||
generation method, was the real problem in every failed attempt.
|
|
||||||
|
|
||||||
Run from `services/tech-step-intent-service/` (this directory):
|
|
||||||
`./.venv/Scripts/python.exe augment_utterances.py`. Rewrites
|
|
||||||
`training_data.py` in place by textual splicing (AST only to *locate* each
|
|
||||||
`utterances=[...]` list's line range — never to regenerate the file). Safe
|
|
||||||
to re-run: a technique already at the current per-locale max is left
|
|
||||||
untouched, and the max itself is recomputed from the file's *current*
|
|
||||||
state each time (so re-running after a manual edit re-equalizes against
|
|
||||||
whatever the new max is, not a stale one).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import sys
|
|
||||||
|
|
||||||
SRC_PATH = "intent_service/training_data.py"
|
|
||||||
|
|
||||||
# Minimal fallback pool — only ever used for the small remainder synonym
|
|
||||||
# substitution can't reach (see this module's own doc comment for why 2,
|
|
||||||
# not the 12/10 tried in earlier, failed attempts).
|
|
||||||
_FR_FRAMES = ["il faut {u}", "veillez à {u}"]
|
|
||||||
_EN_FRAMES = ["make sure to {u}", "remember to {u}"]
|
|
||||||
|
|
||||||
|
|
||||||
def _is_fr_infinitive_led(u: str) -> bool:
|
|
||||||
first = u.split(" ", 1)[0].lower()
|
|
||||||
return first.endswith(("er", "ir", "re")) and len(first) > 2
|
|
||||||
|
|
||||||
|
|
||||||
_EN_VERB_WHITELIST = {
|
|
||||||
"make", "add", "pour", "mix", "stir", "cut", "place", "cover", "remove", "heat", "let",
|
|
||||||
"keep", "turn", "cook", "bake", "roast", "grill", "fry", "boil", "simmer", "whisk", "fold",
|
|
||||||
"chop", "mince", "peel", "drain", "season", "rest", "plate", "coat", "melt", "sauté", "saute",
|
|
||||||
"braise", "blanch", "marinate", "brown", "glaze", "thicken", "reduce", "dilute", "loosen",
|
|
||||||
"moisten", "sift", "toast", "zest", "scald", "pod", "shell", "hollow", "shock", "emulsify",
|
|
||||||
"decant", "dust", "sweat", "rub", "punch", "confit", "caramelize", "score", "line", "clarify",
|
|
||||||
"stew", "dice", "fillet", "proof", "poach", "pasteurize", "sterilize", "can", "preserve",
|
|
||||||
"tie", "truss", "baste", "spoon", "brush", "whip", "beat", "work", "sear", "flatten", "press",
|
|
||||||
"knead", "run", "cool", "warm", "combine", "blend", "arrange", "present", "sprinkle", "strain",
|
|
||||||
"separate", "bring", "grate", "continue", "deglaze", "scrape", "char", "break", "slice", "set",
|
|
||||||
"adjust", "switch", "secure", "mark", "butter", "crush", "julienne", "reheat", "smother",
|
|
||||||
"build", "scoop", "plunge", "increase", "pass", "collect", "have", "salt", "soak",
|
|
||||||
}
|
|
||||||
_EN_ADVERB_SKIP = {
|
|
||||||
"coarsely", "roughly", "finely", "quickly", "lightly", "briefly", "gently", "carefully",
|
|
||||||
"gradually", "very", "thoroughly", "evenly", "generously", "slowly", "thinly", "deep", "blind",
|
|
||||||
"dry",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _is_en_imperative_led(u: str) -> bool:
|
|
||||||
words = u.lower().replace(",", "").split()
|
|
||||||
if not words:
|
|
||||||
return False
|
|
||||||
first = words[0]
|
|
||||||
if first in _EN_VERB_WHITELIST:
|
|
||||||
return True
|
|
||||||
if first in _EN_ADVERB_SKIP and len(words) > 1:
|
|
||||||
return words[1] in _EN_VERB_WHITELIST
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _frame_variants(existing: list[str], frames: list[str], is_led) -> list[str]:
|
|
||||||
sources = [u for u in existing if is_led(u)]
|
|
||||||
if not sources:
|
|
||||||
return []
|
|
||||||
seen = set(existing)
|
|
||||||
out: list[str] = []
|
|
||||||
for frame in frames:
|
|
||||||
for u in sources:
|
|
||||||
candidate = frame.format(u=u)
|
|
||||||
if candidate in seen:
|
|
||||||
continue
|
|
||||||
seen.add(candidate)
|
|
||||||
out.append(candidate)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _synonym_variants(existing: list[str], synonyms: list[str], locale: str) -> list[str]:
|
|
||||||
"""Substitutes every *other* synonym in place of whichever synonym an
|
|
||||||
existing utterance's leading phrase exactly matches — see this
|
|
||||||
module's own doc comment for why this is the primary generation
|
|
||||||
strategy.
|
|
||||||
|
|
||||||
Both the matched *and* the replacement synonym must independently pass
|
|
||||||
`_is_fr_infinitive_led`/`_is_en_imperative_led` — a technique's
|
|
||||||
`synonyms` list mixes genuine verb forms ("mijoter", "frémir") with
|
|
||||||
noun/adjective phrases used the same way a keyword-matcher needs them
|
|
||||||
but never as a sentence's own leading verb ("à petit feu", "gros
|
|
||||||
bouillons", "huile de friture") — without this check, swapping the
|
|
||||||
verb "frémir" for the noun phrase "à petit feu" inside "laisser
|
|
||||||
frémir..." produces a syntactically broken sentence ("à petit feu
|
|
||||||
..."), not just a stylistically different one. Filtering the
|
|
||||||
replacement pool to the same grammatical shape as the ones this
|
|
||||||
function already accepts as *sources* keeps every substitution a
|
|
||||||
like-for-like swap."""
|
|
||||||
if len(synonyms) < 2:
|
|
||||||
return []
|
|
||||||
is_led = _is_fr_infinitive_led if locale == "fr" else _is_en_imperative_led
|
|
||||||
seen = set(existing)
|
|
||||||
sorted_synonyms = sorted({syn for syn in synonyms if is_led(syn)}, key=len, reverse=True)
|
|
||||||
if len(sorted_synonyms) < 2:
|
|
||||||
return []
|
|
||||||
out: list[str] = []
|
|
||||||
for u in existing:
|
|
||||||
lower_u = u.lower()
|
|
||||||
matched = next(
|
|
||||||
(
|
|
||||||
syn
|
|
||||||
for syn in sorted_synonyms
|
|
||||||
if lower_u == syn.lower() or lower_u.startswith(f"{syn.lower()} ")
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if matched is None:
|
|
||||||
continue
|
|
||||||
rest = u[len(matched) :]
|
|
||||||
for syn in sorted_synonyms:
|
|
||||||
if syn == matched:
|
|
||||||
continue
|
|
||||||
candidate = f"{syn}{rest}"
|
|
||||||
if candidate in seen:
|
|
||||||
continue
|
|
||||||
seen.add(candidate)
|
|
||||||
out.append(candidate)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def top_up(existing: list[str], synonyms: list[str], target: int, locale: str) -> list[str]:
|
|
||||||
if len(existing) >= target:
|
|
||||||
return []
|
|
||||||
needed = target - len(existing)
|
|
||||||
pool = _synonym_variants(existing, synonyms, locale)
|
|
||||||
if len(pool) < needed:
|
|
||||||
frames = _FR_FRAMES if locale == "fr" else _EN_FRAMES
|
|
||||||
is_led = _is_fr_infinitive_led if locale == "fr" else _is_en_imperative_led
|
|
||||||
already = set(existing) | set(pool)
|
|
||||||
for candidate in _frame_variants(existing, frames, is_led):
|
|
||||||
if candidate in already:
|
|
||||||
continue
|
|
||||||
pool.append(candidate)
|
|
||||||
already.add(candidate)
|
|
||||||
return pool[:needed]
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
with open(SRC_PATH, encoding="utf-8") as f:
|
|
||||||
source = f.read()
|
|
||||||
tree = ast.parse(source)
|
|
||||||
lines = source.splitlines(keepends=True)
|
|
||||||
|
|
||||||
module_body = tree.body
|
|
||||||
training_data_list = None
|
|
||||||
for node in module_body:
|
|
||||||
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
||||||
if node.target.id == "TECH_STEP_TRAINING_DATA":
|
|
||||||
training_data_list = node.value
|
|
||||||
break
|
|
||||||
if training_data_list is None or not isinstance(training_data_list, ast.List):
|
|
||||||
print("Could not locate TECH_STEP_TRAINING_DATA list", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# First pass: collect every entry's current per-locale utterance/synonym
|
|
||||||
# lists and find each locale's own current max — the equalization
|
|
||||||
# target, not a number picked separately from the corpus itself.
|
|
||||||
parsed: list[tuple[str, str, ast.List, list[str], list[str]]] = []
|
|
||||||
targets = {"fr": 0, "en": 0}
|
|
||||||
for entry_call in training_data_list.elts:
|
|
||||||
assert isinstance(entry_call, ast.Call)
|
|
||||||
uid = None
|
|
||||||
for kw in entry_call.keywords:
|
|
||||||
if kw.arg == "uid":
|
|
||||||
assert isinstance(kw.value, ast.Constant)
|
|
||||||
uid = kw.value.value
|
|
||||||
for kw in entry_call.keywords:
|
|
||||||
if kw.arg not in ("fr", "en"):
|
|
||||||
continue
|
|
||||||
locale = kw.arg
|
|
||||||
locale_call = kw.value
|
|
||||||
assert isinstance(locale_call, ast.Call)
|
|
||||||
utterances_list_node = None
|
|
||||||
synonyms_list_node = None
|
|
||||||
for inner_kw in locale_call.keywords:
|
|
||||||
if inner_kw.arg == "utterances":
|
|
||||||
utterances_list_node = inner_kw.value
|
|
||||||
elif inner_kw.arg == "synonyms":
|
|
||||||
synonyms_list_node = inner_kw.value
|
|
||||||
if utterances_list_node is None:
|
|
||||||
continue
|
|
||||||
assert isinstance(utterances_list_node, ast.List)
|
|
||||||
existing = [
|
|
||||||
elt.value for elt in utterances_list_node.elts if isinstance(elt, ast.Constant)
|
|
||||||
]
|
|
||||||
synonyms = (
|
|
||||||
[elt.value for elt in synonyms_list_node.elts if isinstance(elt, ast.Constant)]
|
|
||||||
if isinstance(synonyms_list_node, ast.List)
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
targets[locale] = max(targets[locale], len(existing))
|
|
||||||
parsed.append((uid, locale, utterances_list_node, existing, synonyms))
|
|
||||||
|
|
||||||
print(f"Equalizing to the corpus's own current max — fr: {targets['fr']}, en: {targets['en']}")
|
|
||||||
|
|
||||||
insertions: list[tuple[int, str, list[str]]] = []
|
|
||||||
total_added = 0
|
|
||||||
shortfalls: list[tuple[str, str, int]] = []
|
|
||||||
|
|
||||||
for uid, locale, utterances_list_node, existing, synonyms in parsed:
|
|
||||||
target = targets[locale]
|
|
||||||
new_ones = top_up(existing, synonyms, target, locale)
|
|
||||||
final_count = len(existing) + len(new_ones)
|
|
||||||
if final_count < target:
|
|
||||||
shortfalls.append((uid, locale, final_count))
|
|
||||||
if not new_ones:
|
|
||||||
continue
|
|
||||||
last_elt = utterances_list_node.elts[-1]
|
|
||||||
insert_after_line = last_elt.end_lineno - 1
|
|
||||||
indent = lines[insert_after_line][
|
|
||||||
: len(lines[insert_after_line]) - len(lines[insert_after_line].lstrip())
|
|
||||||
]
|
|
||||||
new_lines = [f'{indent}"{s}",\n' for s in new_ones]
|
|
||||||
insertions.append((insert_after_line, uid, new_lines))
|
|
||||||
total_added += len(new_ones)
|
|
||||||
|
|
||||||
insertions.sort(key=lambda t: t[0], reverse=True)
|
|
||||||
for line_idx, uid, new_lines in insertions:
|
|
||||||
lines[line_idx + 1 : line_idx + 1] = new_lines
|
|
||||||
|
|
||||||
with open(SRC_PATH, "w", encoding="utf-8", newline="\n") as f:
|
|
||||||
f.writelines(lines)
|
|
||||||
|
|
||||||
print(f"Added {total_added} new utterances across {len(insertions)} (technique, locale) pairs.")
|
|
||||||
if shortfalls:
|
|
||||||
print(f"{len(shortfalls)} (uid, locale) pair(s) still below their locale's target — not")
|
|
||||||
print("enough synonym variety to reach full equalization:")
|
|
||||||
for uid, locale, count in shortfalls:
|
|
||||||
print(f" {uid} ({locale}): {count}/{targets[locale]}")
|
|
||||||
else:
|
|
||||||
print("Every technique now has exactly the same utterance count as every other, per locale.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
"""Microservice de détection d'intention (technique de cuisine).
|
|
||||||
|
|
||||||
Remplace le pipeline `node-nlp` qui vivait dans `apps/api`
|
|
||||||
(`TechStepClassifierService`, `apps/api/src/lib/recipe-matching/tech-step-matcher.ts`) :
|
|
||||||
NER par phrases (synonymes) + classification d'intention (textcat), les deux
|
|
||||||
entraînés à la demande depuis un corpus qui reste possédé par `apps/api`
|
|
||||||
(`TECH_STEP_TRAINING_DATA`) et poussé ici via `POST /v1/train`.
|
|
||||||
|
|
||||||
Ce service ne touche jamais Postgres — voir `services/tech-step-llm-worker`
|
|
||||||
pour le précédent architectural (même posture : aucun accès DB direct,
|
|
||||||
tout passe par HTTP, la résolution `TechStep.key -> id` reste côté `apps/api`).
|
|
||||||
"""
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
"""Configuration du service, lue depuis l'environnement (`pydantic-settings`).
|
|
||||||
|
|
||||||
Contrairement à `requireInternalWorker` côté `apps/api`
|
|
||||||
(`apps/api/src/middlewares/require-internal-worker.ts`), qui tolère un
|
|
||||||
`INTERNAL_WORKER_SECRET` absent (le worker LLM est un job de fond
|
|
||||||
optionnel) et échoue "juste" requête par requête dans ce cas, ce service est
|
|
||||||
une dépendance coeur : `INTENT_SERVICE_SECRET` absent doit empêcher
|
|
||||||
`uvicorn` de démarrer du tout plutôt que de démarrer dans un état où chaque
|
|
||||||
requête échouerait silencieusement en boucle — `Settings` n'a donc aucune
|
|
||||||
valeur par défaut ni type optionnel pour ce champ, la validation Pydantic
|
|
||||||
lève dès l'import de ce module si la variable manque.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
# `env_file=".env"` : lu uniquement en dev natif (`cp .env.example .env`,
|
|
||||||
# voir le README de ce service) — sans effet en Docker, où
|
|
||||||
# docker-compose.yml passe les variables directement en `environment:`
|
|
||||||
# et où aucun `.env` n'est copié dans l'image. Un `.env` absent n'est pas
|
|
||||||
# une erreur ici (pydantic-settings ignore silencieusement un fichier
|
|
||||||
# manquant) ; c'est bien `intent_service_secret` ci-dessous, sans valeur
|
|
||||||
# par défaut, qui fait échouer le démarrage si la variable n'est
|
|
||||||
# disponible par aucune des deux voies.
|
|
||||||
#
|
|
||||||
# `case_sensitive` par défaut (False) : `INTENT_SERVICE_SECRET` (la
|
|
||||||
# convention majuscule utilisée partout ailleurs dans le repo, cf.
|
|
||||||
# `docker-compose.yml`/`.env.example`) matche bien le champ
|
|
||||||
# `intent_service_secret` ci-dessous.
|
|
||||||
model_config = SettingsConfigDict(env_file=".env")
|
|
||||||
|
|
||||||
# Secret partagé attendu sur le header `X-Intent-Service-Secret` de
|
|
||||||
# chaque requête (sauf `GET /health`) — voir `security.py`. Doit matcher
|
|
||||||
# `INTENT_SERVICE_SECRET` côté `apps/api/src/config/env.ts`.
|
|
||||||
intent_service_secret: str
|
|
||||||
|
|
||||||
# Pas de `port` ici : `uvicorn` prend son port en argument de ligne de
|
|
||||||
# commande (`--port`, voir le Dockerfile et le README de ce service),
|
|
||||||
# jamais lu depuis `Settings` — une variable d'env dupliquant ce que la
|
|
||||||
# commande de démarrage fixe déjà explicitement n'aurait aucun lecteur.
|
|
||||||
|
|
||||||
# Niveau du logging structuré (`logging_config.py`) — voir ce module pour
|
|
||||||
# le format. `INFO` par défaut : c'est à ce niveau que `routes/process.py`
|
|
||||||
# journalise chaque input/output du pipeline NLP, et que
|
|
||||||
# `pipeline_registry.py` journalise l'entraînement au démarrage, pour
|
|
||||||
# qu'un déploiement par défaut les voie sans configuration
|
|
||||||
# supplémentaire (`docker logs`/Portainer).
|
|
||||||
log_level: str = "INFO"
|
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
|
|
@ -1,481 +0,0 @@
|
||||||
"""Pipeline spaCy pour UNE locale — l'équivalent Python de ce que
|
|
||||||
`node-nlp`'s `NlpManager` faisait pour cette locale dans
|
|
||||||
`TechStepClassifierService` (`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`) :
|
|
||||||
NER par entités enum (ici un `PhraseMatcher`) + classification d'intention
|
|
||||||
(ici un `textcat`), les deux entraînés à partir du corpus possédé par ce
|
|
||||||
service lui-même (`training_data.TECH_STEP_TRAINING_DATA` — plus poussé par
|
|
||||||
`apps/api` via HTTP, voir `pipeline_registry.py`).
|
|
||||||
|
|
||||||
Le modèle de base spaCy (tokenizer + vecteurs + le composant
|
|
||||||
`diacritics_normalizer` défini plus bas) est chargé une seule fois
|
|
||||||
(`preload()`, appelé au démarrage du process — voir `main.py` — pas
|
|
||||||
paresseusement au premier `train()`, pour que `GET /health` ne devienne
|
|
||||||
`200` qu'une fois ce coût payé) puis réutilisé à chaque `train()` : seul le
|
|
||||||
`textcat` (retiré puis rajouté à neuf) et le `PhraseMatcher` (remplacé) sont
|
|
||||||
reconstruits à chaque appel, jamais le tokenizer/les vecteurs. Rien n'est
|
|
||||||
jamais persisté sur disque — `training_data.py` reste l'unique source de
|
|
||||||
vérité, reconstruite en mémoire depuis zéro à chaque démarrage du process.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import random
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
import spacy
|
|
||||||
from spacy.language import Language
|
|
||||||
from spacy.matcher import PhraseMatcher
|
|
||||||
from spacy.tokens import Doc, Span
|
|
||||||
from spacy.training import Example
|
|
||||||
from spacy.util import filter_spans, fix_random_seed, minibatch
|
|
||||||
|
|
||||||
from . import utensil_vocabulary
|
|
||||||
from .text_normalization import normalize_text
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Modèle spaCy de base par locale — voir pyproject.toml pour la version
|
|
||||||
# pinnée exacte. `md` (pas `sm`) : conserve les vecteurs de mots, inutilisés
|
|
||||||
# par le pipeline v1 (textcat bag-of-words) mais retenus pour l'ambition
|
|
||||||
# future de similarité sémantique (voir le README de ce service).
|
|
||||||
SUPPORTED_LOCALES = {
|
|
||||||
"fr": "fr_core_news_md",
|
|
||||||
"en": "en_core_web_md",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Composants du modèle de base non utilisés par ce pipeline (on ne s'appuie
|
|
||||||
# ni sur le NER générique de spaCy, ni sur l'analyse syntaxique/morphologique
|
|
||||||
# — seuls le tokenizer et les vecteurs de mots restent nécessaires) : les
|
|
||||||
# exclure au chargement évite le coût mémoire/CPU de composants qui ne
|
|
||||||
# tourneraient jamais.
|
|
||||||
_EXCLUDED_COMPONENTS = ["parser", "ner", "tagger", "morphologizer", "attribute_ruler", "lemmatizer"]
|
|
||||||
|
|
||||||
_TEXTCAT_PIPE_NAME = "textcat"
|
|
||||||
|
|
||||||
# Nombre d'itérations d'entraînement du textcat et taille de minibatch —
|
|
||||||
# calibrés empiriquement contre le corpus réel (`training_data.py`), pas
|
|
||||||
# seulement contre les petits corpus jouets des tests de ce fichier. Trop
|
|
||||||
# peu d'itérations laisse des clauses correctement classifiées (bon argmax)
|
|
||||||
# mais avec une confiance dérisoire — bien en dessous de tout seuil
|
|
||||||
# raisonnable pour `CONFIDENCE_THRESHOLD` (`tech-step-matcher.ts`).
|
|
||||||
#
|
|
||||||
# Trois passes de calibration successives, toutes mesurées contre le
|
|
||||||
# corpus réel (74 techniques) :
|
|
||||||
# 1. `150` itérations (calibré pour le corpus original, ~26 techniques) ne
|
|
||||||
# passe plus à l'échelle une fois élargi : `150` sur 74 classes
|
|
||||||
# dépassait 17 minutes pour une seule locale, constaté en CI.
|
|
||||||
# 2. `40` itérations, `examples` limité aux `utterances` (pas les
|
|
||||||
# `synonyms`) : ~200s/locale, mais confiance faible sur les clauses
|
|
||||||
# ancrées sans paraphrase entraînée (`simmer`/`cook`/`bake` ~0.25-0.34).
|
|
||||||
# 3. **Configuration actuelle** : les `synonyms` de chaque technique sont
|
|
||||||
# désormais aussi des exemples d'entraînement du textcat (voir plus bas
|
|
||||||
# dans `train()`) — un signal "mot-clé isolé -> sa propre technique"
|
|
||||||
# qui manquait complètement avant. À `_TRAINING_ITERATIONS` inchangé
|
|
||||||
# (40), le nombre d'exemples par époque grimpe de ~286 à ~749 et le
|
|
||||||
# temps d'entraînement suit (~535s/locale) ; réduire à `25` retrouve un
|
|
||||||
# temps proche de l'étape 2 (~336s/locale, ~670s pour fr+en combinés)
|
|
||||||
# tout en gardant l'essentiel du gain de confiance apporté par les
|
|
||||||
# synonymes : melt ~0.89, preheat ~0.77, compote ~0.78, julienne ~0.76,
|
|
||||||
# zest ~0.66, bake ~0.62, cook ~0.38, simmer ~0.31 — le plus faible
|
|
||||||
# observé, mais désormais nettement au-dessus du seuil de confiance
|
|
||||||
# (contre ~0.25, sous le seuil d'alors, à l'étape 2). Bruit
|
|
||||||
# hors-vocabulaire toujours négligeable (anglais via le classifieur
|
|
||||||
# français : `~0.02`). Une vraie repasse de
|
|
||||||
# `calibrate-tech-step-threshold.ts` contre `TECH_STEP_EVAL_DATASET`
|
|
||||||
# reste nécessaire pour confirmer/affiner ces valeurs (voir
|
|
||||||
# `CONFIDENCE_THRESHOLD`'s propre commentaire, `tech-step-matcher.ts`)
|
|
||||||
# — ce qui précède est une mesure manuelle ponctuelle, pas un
|
|
||||||
# remplacement de cette calibration.
|
|
||||||
_TRAINING_ITERATIONS = 25
|
|
||||||
_TRAINING_BATCH_SIZE = 16
|
|
||||||
# Arrêt anticipé : `_TRAINING_ITERATIONS` reste le plafond (le pire cas ne
|
|
||||||
# change pas), un corpus/locale qui converge plus vite n'a pas à payer les
|
|
||||||
# itérations restantes pour rien. Une époque compte comme "sans progrès"
|
|
||||||
# quand sa perte totale ne descend pas d'au moins `_EARLY_STOPPING_MIN_DELTA`
|
|
||||||
# sous la meilleure perte vue jusqu'ici ; `_EARLY_STOPPING_PATIENCE` époques
|
|
||||||
# consécutives sans progrès arrêtent l'entraînement.
|
|
||||||
#
|
|
||||||
# Mesuré contre le corpus réel (74 techniques, budget de 40 itérations,
|
|
||||||
# avant le passage à 25) : ne s'est jamais déclenché — la perte continuait
|
|
||||||
# de baisser significativement sur toute la plage (cohérent avec la
|
|
||||||
# confiance qui grimpait encore nettement entre 15 et 40 itérations, voir
|
|
||||||
# le commentaire de `_TRAINING_ITERATIONS`). Ce n'est donc pas un gain de
|
|
||||||
# temps aujourd'hui, mais un filet de sécurité peu coûteux pour la suite : si
|
|
||||||
# `_TRAINING_ITERATIONS` est un jour augmenté pour une meilleure confiance,
|
|
||||||
# ceci évite de payer des itérations supplémentaires une fois la
|
|
||||||
# convergence réellement atteinte, sans qu'il faille retrouver le bon
|
|
||||||
# plafond à la main à chaque changement du corpus.
|
|
||||||
_EARLY_STOPPING_PATIENCE = 3
|
|
||||||
_EARLY_STOPPING_MIN_DELTA = 0.001
|
|
||||||
# Abaissé de `0.2` avec le reste de cette recalibration — `0.1` régularise
|
|
||||||
# encore contre la petite taille du corpus par technique tout en laissant
|
|
||||||
# plus de signal passer à chaque pas, ce qui a mesurablement aidé la
|
|
||||||
# confiance finale sans signe de sur-ajustement (le bruit hors-vocabulaire
|
|
||||||
# reste aussi bas qu'avant, voir ci-dessus).
|
|
||||||
_TRAINING_DROPOUT = 0.1
|
|
||||||
# Seed fixe — un warm-up reproductible d'un redémarrage à l'autre (même
|
|
||||||
# corpus en entrée) est préférable à un score qui varie légèrement à chaque
|
|
||||||
# déploiement pour la même donnée, en particulier pendant la calibration du
|
|
||||||
# seuil de confiance côté apps/api.
|
|
||||||
_TRAINING_SEED = 0
|
|
||||||
|
|
||||||
|
|
||||||
class _DiacriticsNormalizer:
|
|
||||||
"""Composant de pipeline réécrivant `token.norm_` avec `normalize_text()`
|
|
||||||
(le port Python de `normalizeText()` côté `apps/api`) pour chaque token.
|
|
||||||
|
|
||||||
Point clé : ce composant tourne aussi bien sur les `Doc` construits pour
|
|
||||||
les *patterns* du `PhraseMatcher` (voir `LocalePipeline.train`) que sur
|
|
||||||
le *texte cible* passé à `process()` — les deux passent donc par
|
|
||||||
exactement la même normalisation, ce qui garantit qu'un synonyme comme
|
|
||||||
"mijoter" matche indifféremment "MIJOTER"/"mijoté"/"Mijotée" dans le
|
|
||||||
texte, reproduisant le comportement `ner.threshold: 1` (exact après
|
|
||||||
normalisation, sans tolérance floue Levenshtein) de l'ancien `NlpManager`.
|
|
||||||
Indépendant des `entries` entraînées — ajouté une seule fois par
|
|
||||||
`preload()`, jamais retiré/rajouté par `train()`.
|
|
||||||
|
|
||||||
Opère token par token, sur du texte déjà tokenisé — `normalize_text()`
|
|
||||||
ne fait que réécrire la forme d'un token existant (minuscule, sans
|
|
||||||
diacritique), jamais fusionner/scinder des tokens : les patterns
|
|
||||||
(`nlp.make_doc(synonym)` + ce composant appliqué à la main, voir
|
|
||||||
`LocalePipeline.train`) et le texte cible (`nlp(text)`, pipeline
|
|
||||||
complet) passent donc toujours par le *même* découpage en tokens que
|
|
||||||
le tokenizer du modèle de base leur donne, avant que ce composant n'y
|
|
||||||
touche — pas de risque de désalignement entre les deux.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __call__(self, doc: Doc) -> Doc:
|
|
||||||
for token in doc:
|
|
||||||
token.norm_ = normalize_text(token.text)
|
|
||||||
return doc
|
|
||||||
|
|
||||||
|
|
||||||
@Language.factory("diacritics_normalizer")
|
|
||||||
def _create_diacritics_normalizer(nlp: Language, name: str) -> _DiacriticsNormalizer:
|
|
||||||
return _DiacriticsNormalizer()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TrainEntry:
|
|
||||||
"""Une technique à entraîner pour une locale — construit par
|
|
||||||
`PipelineRegistry.initialize()` depuis `training_data.entries_for_locale`."""
|
|
||||||
|
|
||||||
uid: str
|
|
||||||
synonyms: list[str] = field(default_factory=list)
|
|
||||||
utterances: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Entity:
|
|
||||||
"""Une mention candidate trouvée par un `PhraseMatcher` — offsets
|
|
||||||
caractère `[start, end)`, miroir de `EntityPayload` (`schemas.py`).
|
|
||||||
`kind` distingue de quel `PhraseMatcher` la mention vient (`"technique"`
|
|
||||||
— `self._matcher`, entraîné depuis `training_data.py` — ou `"utensil"`
|
|
||||||
— `self._utensil_matcher`, statique, voir `utensil_vocabulary.py`) :
|
|
||||||
`apps/api`'s `tech-step-matcher.ts` a besoin de savoir laquelle des deux
|
|
||||||
résoudre (`TechStep.key` vs `Utensil.key`)."""
|
|
||||||
|
|
||||||
uid: str
|
|
||||||
start: int
|
|
||||||
end: int
|
|
||||||
kind: str = "technique"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProcessResult:
|
|
||||||
"""Résultat complet d'un `process()` — miroir de `ProcessResponse`
|
|
||||||
(`schemas.py`)."""
|
|
||||||
|
|
||||||
entities: list[Entity]
|
|
||||||
intent: str | None
|
|
||||||
score: float
|
|
||||||
|
|
||||||
|
|
||||||
class UnsupportedLocaleError(ValueError):
|
|
||||||
"""`locale` ne correspond à aucun modèle spaCy connu (voir
|
|
||||||
`SUPPORTED_LOCALES`) — distinct d'une locale simplement "pas encore
|
|
||||||
entraînée" (`LocalePipeline.is_trained is False`), qui n'est pas une
|
|
||||||
erreur (voir `process()`)."""
|
|
||||||
|
|
||||||
|
|
||||||
class LocalePipeline:
|
|
||||||
"""Pipeline spaCy (NER par phrases + textcat) pour une locale donnée.
|
|
||||||
Un `PipelineRegistry` (voir `pipeline_registry.py`) en détient une
|
|
||||||
instance par locale supportée.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, locale: str) -> None:
|
|
||||||
if locale not in SUPPORTED_LOCALES:
|
|
||||||
raise UnsupportedLocaleError(f"Unsupported locale: {locale!r}")
|
|
||||||
self._locale = locale
|
|
||||||
self._model_name = SUPPORTED_LOCALES[locale]
|
|
||||||
# `None` tant que `preload()` n'a pas tourné.
|
|
||||||
self._base_nlp: Language | None = None
|
|
||||||
# `None` tant qu'aucun `train()` n'a réussi — `process()` traite ça
|
|
||||||
# comme "rien à trouver" plutôt qu'une erreur, exactement le
|
|
||||||
# comportement testé côté `apps/api` pour "une locale jamais
|
|
||||||
# entraînée".
|
|
||||||
self._matcher: PhraseMatcher | None = None
|
|
||||||
# Construit une seule fois par `preload()`, jamais par `train()` —
|
|
||||||
# contrairement à `self._matcher`, ce vocabulaire est statique
|
|
||||||
# (`utensil_vocabulary.py`), il n'a pas de contrepartie "corpus
|
|
||||||
# poussé par un appelant" à reconstruire.
|
|
||||||
self._utensil_matcher: PhraseMatcher | None = None
|
|
||||||
self._trained = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_trained(self) -> bool:
|
|
||||||
return self._trained
|
|
||||||
|
|
||||||
def preload(self) -> None:
|
|
||||||
"""Charge le modèle spaCy de base (tokenizer + vecteurs), le
|
|
||||||
composant `diacritics_normalizer`, et construit le `PhraseMatcher`
|
|
||||||
d'ustensiles — idempotent, sans effet si déjà chargé. Appelé au
|
|
||||||
démarrage du process pour les deux locales connues (voir
|
|
||||||
`main.py`), pas paresseusement au premier `train()`.
|
|
||||||
|
|
||||||
Le matcher d'ustensiles est construit ici, pas dans `train()` :
|
|
||||||
contrairement au `PhraseMatcher` de techniques (reconstruit à
|
|
||||||
chaque `train()` depuis les `entries` reçues), le vocabulaire
|
|
||||||
d'ustensiles est statique (`utensil_vocabulary.py`) — rien ne le
|
|
||||||
fait jamais varier d'un appel à l'autre, donc rien ne justifie de
|
|
||||||
payer son coût de construction plus d'une fois par démarrage.
|
|
||||||
"""
|
|
||||||
if self._base_nlp is not None:
|
|
||||||
return
|
|
||||||
nlp = spacy.load(self._model_name, exclude=_EXCLUDED_COMPONENTS)
|
|
||||||
nlp.add_pipe("diacritics_normalizer", first=True)
|
|
||||||
self._base_nlp = nlp
|
|
||||||
|
|
||||||
diacritics_normalizer = nlp.get_pipe("diacritics_normalizer")
|
|
||||||
utensil_matcher = PhraseMatcher(nlp.vocab, attr="NORM")
|
|
||||||
for uid, synonyms in utensil_vocabulary.synonyms_for_locale(self._locale).items():
|
|
||||||
if not synonyms:
|
|
||||||
continue
|
|
||||||
patterns = [diacritics_normalizer(nlp.make_doc(synonym)) for synonym in synonyms]
|
|
||||||
utensil_matcher.add(uid, patterns)
|
|
||||||
self._utensil_matcher = utensil_matcher
|
|
||||||
|
|
||||||
def train(self, entries: list[TrainEntry]) -> tuple[int, int, int]:
|
|
||||||
"""Reconstruit le `textcat` et le `PhraseMatcher` de ce pipeline à
|
|
||||||
partir de `entries` (le tokenizer/les vecteurs restent ceux chargés
|
|
||||||
par `preload()`). Retourne `(label_count, example_count,
|
|
||||||
synonym_count)` pour la journalisation (`pipeline_registry.py`) —
|
|
||||||
`example_count` est le nombre réel d'exemples donnés au `textcat`
|
|
||||||
(`utterances` *et* `synonyms` combinés, voir plus bas), pas
|
|
||||||
seulement `entry.utterances`.
|
|
||||||
|
|
||||||
`entries` vide retombe à `is_trained == False` plutôt que de lever —
|
|
||||||
un appelant qui n'a rien à entraîner pour cette locale obtient le
|
|
||||||
même comportement que "jamais entraîné", pas une erreur 500.
|
|
||||||
"""
|
|
||||||
self.preload()
|
|
||||||
assert self._base_nlp is not None # garanti par preload() ci-dessus
|
|
||||||
|
|
||||||
if _TEXTCAT_PIPE_NAME in self._base_nlp.pipe_names:
|
|
||||||
self._base_nlp.remove_pipe(_TEXTCAT_PIPE_NAME)
|
|
||||||
|
|
||||||
if not entries:
|
|
||||||
self._matcher = None
|
|
||||||
self._trained = False
|
|
||||||
return (0, 0, 0)
|
|
||||||
|
|
||||||
nlp = self._base_nlp
|
|
||||||
# `nlp.make_doc()` ne fait tourner *que* le tokenizer, pas les
|
|
||||||
# composants du pipeline — le `diacritics_normalizer` ajouté par
|
|
||||||
# `preload()` ne tournerait donc jamais sur les `Doc` de patterns
|
|
||||||
# s'ils n'étaient construits qu'avec `make_doc()`, alors que
|
|
||||||
# `process()` appelle `nlp(text)` (le pipeline complet) sur le texte
|
|
||||||
# cible. Sans ce correctif, un synonyme accentué comme "préchauffer"
|
|
||||||
# n'aurait jamais matché "PRÉCHAUFFER"/"Préchauffer" : trouvé en
|
|
||||||
# calibrant contre les cas exacts de `tech-step-matcher.test.ts`
|
|
||||||
# (fr, la locale la plus concernée par les accents) — un synonyme
|
|
||||||
# sans diacritique comme "faire fondre" masquait le bug en semblant
|
|
||||||
# fonctionner par coïncidence. Appliquer explicitement le même
|
|
||||||
# composant aux deux côtés garantit qu'ils passent par la même
|
|
||||||
# normalisation.
|
|
||||||
diacritics_normalizer = nlp.get_pipe("diacritics_normalizer")
|
|
||||||
|
|
||||||
matcher = PhraseMatcher(nlp.vocab, attr="NORM")
|
|
||||||
synonym_count = 0
|
|
||||||
for entry in entries:
|
|
||||||
if not entry.synonyms:
|
|
||||||
continue
|
|
||||||
patterns = [diacritics_normalizer(nlp.make_doc(synonym)) for synonym in entry.synonyms]
|
|
||||||
matcher.add(entry.uid, patterns)
|
|
||||||
synonym_count += len(entry.synonyms)
|
|
||||||
|
|
||||||
# `textcat` (exclusive_classes) exige au moins deux labels (voir
|
|
||||||
# spaCy's error E867) — jamais un problème avec le vrai corpus
|
|
||||||
# (`TECH_STEP_TRAINING_DATA` a ~74 techniques), mais un `entries` à
|
|
||||||
# un seul élément resterait structurellement valide pour le NER
|
|
||||||
# seul : ne pas planter, juste ne pas construire de textcat du tout
|
|
||||||
# (`process()` retombe alors sur `intent: null` via son garde
|
|
||||||
# `if not cats`, exactement comme "rien à classifier"). Journalisé
|
|
||||||
# explicitement — sans ça, "pourquoi cette locale ne classifie
|
|
||||||
# jamais rien" ne serait visible qu'en déduisant `labelCount < 2`
|
|
||||||
# de la ligne "tech-step NLP pipeline trained" (`pipeline_registry.py`).
|
|
||||||
examples: list[Example] = []
|
|
||||||
if len(entries) < 2:
|
|
||||||
logger.warning(
|
|
||||||
"tech-step NLP textcat skipped: fewer than 2 labels, intent classification disabled for this locale",
|
|
||||||
extra={"locale": self._locale, "labelCount": len(entries)},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
textcat = nlp.add_pipe(
|
|
||||||
_TEXTCAT_PIPE_NAME,
|
|
||||||
config={
|
|
||||||
"model": {
|
|
||||||
"@architectures": "spacy.TextCatBOW.v3",
|
|
||||||
"exclusive_classes": True,
|
|
||||||
"ngram_size": 1,
|
|
||||||
"no_output_layer": False,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
for entry in entries:
|
|
||||||
textcat.add_label(entry.uid)
|
|
||||||
|
|
||||||
for entry in entries:
|
|
||||||
cats = {other.uid: 0.0 for other in entries}
|
|
||||||
cats[entry.uid] = 1.0
|
|
||||||
# `synonyms` (déjà utilisés pour le `PhraseMatcher` ci-dessus)
|
|
||||||
# sont aussi de bonnes phrases d'entraînement pour le
|
|
||||||
# `textcat` — un texte réduit au mot-clé lui-même ("fondre",
|
|
||||||
# "faire fondre") est le cas le plus net qui soit pour sa
|
|
||||||
# propre technique, et n'était auparavant vu par le textcat
|
|
||||||
# que noyé dans le contexte plus riche des `utterances`.
|
|
||||||
for text in (*entry.synonyms, *entry.utterances):
|
|
||||||
doc = nlp.make_doc(text)
|
|
||||||
examples.append(Example.from_dict(doc, {"cats": cats}))
|
|
||||||
|
|
||||||
# Graine le RNG Python *et* celui de numpy/thinc sous-jacent à
|
|
||||||
# `nlp.update()` (initialisation des poids, masque de dropout) —
|
|
||||||
# `random.Random(_TRAINING_SEED)` ci-dessous ne couvre que l'ordre
|
|
||||||
# de mélange des exemples choisi par ce module, pas ce que spaCy
|
|
||||||
# fait en interne à chaque pas de gradient.
|
|
||||||
fix_random_seed(_TRAINING_SEED)
|
|
||||||
rng = random.Random(_TRAINING_SEED)
|
|
||||||
if examples:
|
|
||||||
optimizer = nlp.initialize(lambda: examples)
|
|
||||||
best_loss = float("inf")
|
|
||||||
epochs_without_improvement = 0
|
|
||||||
for iteration in range(_TRAINING_ITERATIONS):
|
|
||||||
rng.shuffle(examples)
|
|
||||||
losses: dict[str, float] = {}
|
|
||||||
for batch in minibatch(examples, size=_TRAINING_BATCH_SIZE):
|
|
||||||
nlp.update(batch, sgd=optimizer, drop=_TRAINING_DROPOUT, losses=losses)
|
|
||||||
epoch_loss = losses.get(_TEXTCAT_PIPE_NAME, 0.0)
|
|
||||||
# Arrêt anticipé — voir `_EARLY_STOPPING_PATIENCE`'s propre
|
|
||||||
# commentaire. `_TRAINING_ITERATIONS` reste le plafond
|
|
||||||
# (pire cas inchangé), ceci ne fait que raccourcir les
|
|
||||||
# cas qui convergent plus vite.
|
|
||||||
if epoch_loss < best_loss - _EARLY_STOPPING_MIN_DELTA:
|
|
||||||
best_loss = epoch_loss
|
|
||||||
epochs_without_improvement = 0
|
|
||||||
else:
|
|
||||||
epochs_without_improvement += 1
|
|
||||||
if epochs_without_improvement >= _EARLY_STOPPING_PATIENCE:
|
|
||||||
logger.info(
|
|
||||||
"tech-step NLP textcat training stopped early",
|
|
||||||
extra={
|
|
||||||
"locale": self._locale,
|
|
||||||
"iteration": iteration + 1,
|
|
||||||
"maxIterations": _TRAINING_ITERATIONS,
|
|
||||||
"finalLoss": epoch_loss,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# Des `entries` avec des `uid` mais aucune `utterance` nulle
|
|
||||||
# part (corpus incomplet) : le textcat a des labels mais rien
|
|
||||||
# pour apprendre à les distinguer — toujours initialisé pour
|
|
||||||
# rester un pipeline valide ; `process()` renverra alors un
|
|
||||||
# score ~uniforme entre labels. Ce n'est pas ce module qui doit
|
|
||||||
# juger la qualité du corpus reçu (voir `tech-step-eval-runner.ts`
|
|
||||||
# côté apps/api pour ce rôle).
|
|
||||||
nlp.initialize()
|
|
||||||
|
|
||||||
self._matcher = matcher
|
|
||||||
self._trained = True
|
|
||||||
return (len(entries), len(examples), synonym_count)
|
|
||||||
|
|
||||||
def process(self, text: str) -> ProcessResult:
|
|
||||||
"""Reproduit la forme de `NlpManager.process(locale, text)` : les
|
|
||||||
entités candidates (NER) et le verdict du classifieur d'intention
|
|
||||||
sur `text` tel quel — que ce soit la description complète ou une
|
|
||||||
clause déjà découpée côté `apps/api`, ce module ne le sait pas et ne
|
|
||||||
s'en soucie pas, exactement comme l'ancien `NlpManager`.
|
|
||||||
|
|
||||||
`intent` vaut `None` dans deux cas distincts, tous deux silencieux
|
|
||||||
côté retour (voir le log d'avertissement de `train()` pour repérer
|
|
||||||
le second en amont) : `text` vide/blanc, ou `doc.cats` vide parce
|
|
||||||
que `train()` a reçu moins de deux labels pour cette locale (le
|
|
||||||
textcat n'a alors jamais été construit — voir son propre
|
|
||||||
commentaire).
|
|
||||||
"""
|
|
||||||
if not self._trained or self._base_nlp is None or self._matcher is None or not text.strip():
|
|
||||||
return ProcessResult(entities=[], intent=None, score=0.0)
|
|
||||||
|
|
||||||
doc = self._base_nlp(text)
|
|
||||||
|
|
||||||
# A technique's own synonym list can legitimately contain one phrase
|
|
||||||
# nested inside another (`melt`'s "fondre" is a literal substring of
|
|
||||||
# its own "faire fondre") — the `PhraseMatcher` reports *both* as
|
|
||||||
# separate matches at overlapping positions, which without
|
|
||||||
# resolution would hand `splitIntoClauses` (apps/api) two candidates
|
|
||||||
# for what a human reads as one mention, producing the same
|
|
||||||
# techStepId twice in the final result. `filter_spans` keeps only
|
|
||||||
# the longest match at each position (so "faire fondre" wins over
|
|
||||||
# the "fondre" it contains) — found by a real regression in
|
|
||||||
# `tech-step-matcher.test.ts`'s "detects several distinct
|
|
||||||
# techniques..." case once this service replaced node-nlp (which
|
|
||||||
# apparently resolved this internally; nothing here recreates that
|
|
||||||
# by choice, `filter_spans` is spaCy's own documented tool for
|
|
||||||
# exactly this "one span per position" problem, e.g. as used for
|
|
||||||
# NER-style outputs).
|
|
||||||
matched_spans = [
|
|
||||||
Span(doc, start, end, label=match_id) for match_id, start, end in self._matcher(doc)
|
|
||||||
]
|
|
||||||
technique_entities = [
|
|
||||||
Entity(
|
|
||||||
uid=self._base_nlp.vocab.strings[span.label],
|
|
||||||
start=span.start_char,
|
|
||||||
end=span.end_char,
|
|
||||||
kind="technique",
|
|
||||||
)
|
|
||||||
for span in filter_spans(matched_spans)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Second, independent `PhraseMatcher` pass for ustensiles — run and
|
|
||||||
# `filter_spans`-resolved *separately* from the technique pass
|
|
||||||
# above: the two matchers' candidates never compete for the same
|
|
||||||
# position (a longer utensil match must never swallow/be swallowed
|
|
||||||
# by a technique match the way two overlapping technique synonyms
|
|
||||||
# do), only overlaps *within* the same matcher are the known
|
|
||||||
# problem `filter_spans` exists for (see the technique pass's own
|
|
||||||
# comment above).
|
|
||||||
utensil_entities: list[Entity] = []
|
|
||||||
if self._utensil_matcher is not None:
|
|
||||||
utensil_spans = [
|
|
||||||
Span(doc, start, end, label=match_id)
|
|
||||||
for match_id, start, end in self._utensil_matcher(doc)
|
|
||||||
]
|
|
||||||
utensil_entities = [
|
|
||||||
Entity(
|
|
||||||
uid=self._base_nlp.vocab.strings[span.label],
|
|
||||||
start=span.start_char,
|
|
||||||
end=span.end_char,
|
|
||||||
kind="utensil",
|
|
||||||
)
|
|
||||||
for span in filter_spans(utensil_spans)
|
|
||||||
]
|
|
||||||
|
|
||||||
entities = sorted(technique_entities + utensil_entities, key=lambda entity: entity.start)
|
|
||||||
|
|
||||||
cats = doc.cats
|
|
||||||
if not cats:
|
|
||||||
return ProcessResult(entities=entities, intent=None, score=0.0)
|
|
||||||
intent = max(cats, key=cats.get)
|
|
||||||
return ProcessResult(entities=entities, intent=intent, score=cats[intent])
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
"""Logging structuré — même convention que `LoggerService` côté `apps/api`
|
|
||||||
(`apps/api/src/lib/logger.service.ts`) : une ligne JSON par évènement
|
|
||||||
(`timestamp`, `level`, `message`, + le reste des champs fournis fusionné),
|
|
||||||
jamais du texte libre, pour rester grep/parse-able par `docker logs`/
|
|
||||||
Portainer ou un agrégateur de logs — cohérent avec le reste du repo plutôt
|
|
||||||
qu'un format propre à ce seul service.
|
|
||||||
|
|
||||||
Configuré une fois au démarrage (`main.py`) plutôt que par un `print()` ad
|
|
||||||
hoc dans chaque route — `routes/process.py`/`pipeline_registry.py` appellent
|
|
||||||
`logging.getLogger(__name__)` normalement, ce module ne fait que brancher le
|
|
||||||
formateur JSON sur la racine du logging Python.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
class _JsonFormatter(logging.Formatter):
|
|
||||||
"""Sérialise chaque `LogRecord` en une ligne JSON. Les champs
|
|
||||||
supplémentaires passés via `logger.info(msg, extra={...})` sont fusionnés
|
|
||||||
tels quels dans l'objet — c'est ce que `routes/process.py` utilise pour
|
|
||||||
joindre `locale`/`text`/`entities`/`intent`/`score` à la ligne."""
|
|
||||||
|
|
||||||
# Attributs standards de `LogRecord` — tout le reste posé sur le record
|
|
||||||
# (via `extra=`) est un champ métier ajouté par l'appelant, à fusionner
|
|
||||||
# dans la sortie JSON.
|
|
||||||
_STANDARD_ATTRS = frozenset(logging.LogRecord("", 0, "", 0, "", None, None).__dict__.keys())
|
|
||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
|
|
||||||
"level": record.levelname.lower(),
|
|
||||||
"message": record.getMessage(),
|
|
||||||
}
|
|
||||||
extra_fields = {
|
|
||||||
key: value for key, value in record.__dict__.items() if key not in self._STANDARD_ATTRS
|
|
||||||
}
|
|
||||||
payload.update(extra_fields)
|
|
||||||
if record.exc_info:
|
|
||||||
payload["error"] = self.formatException(record.exc_info)
|
|
||||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(level: str) -> None:
|
|
||||||
"""Branche le formateur JSON sur la racine du logging Python — appelé
|
|
||||||
une fois au démarrage (`main.py`), avant que `routes/*` ne journalisent
|
|
||||||
quoi que ce soit."""
|
|
||||||
# L'encodage par défaut de `sys.stdout` suit la locale de l'OS/console,
|
|
||||||
# pas forcément UTF-8 — sur Windows en particulier, garder ce défaut
|
|
||||||
# produit de vrais octets invalides (pas juste un affichage terminal
|
|
||||||
# trompeur) pour tout texte accentué journalisé par `routes/process.py`
|
|
||||||
# (le texte réel des étapes de recette, en français) — trouvé en
|
|
||||||
# vérifiant les octets bruts d'un log réel, pas juste son affichage.
|
|
||||||
# `reconfigure` existe sur `sys.stdout` dans toute exécution Python
|
|
||||||
# normale (pas dans certains contextes embarqués/redirigés exotiques) —
|
|
||||||
# protégé par `hasattr` pour ne jamais faire planter le démarrage pour un
|
|
||||||
# souci de confort d'affichage.
|
|
||||||
if hasattr(sys.stdout, "reconfigure"):
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
|
||||||
handler = logging.StreamHandler(sys.stdout)
|
|
||||||
handler.setFormatter(_JsonFormatter())
|
|
||||||
root = logging.getLogger()
|
|
||||||
root.handlers = [handler]
|
|
||||||
root.setLevel(level)
|
|
||||||
|
|
||||||
# spaCy/thinc journalisent leur propre chatter interne ("Created
|
|
||||||
# vocabulary", "Finished initializing nlp object"...) sur le logger
|
|
||||||
# `"spacy"`, qui propage jusqu'à la racine et se retrouverait donc
|
|
||||||
# mélangé aux lignes input/output de `routes/process.py`/l'entraînement
|
|
||||||
# journalisé par `pipeline_registry.py`
|
|
||||||
# — ce sont ces dernières que ce service existe pour rendre visibles, pas
|
|
||||||
# le détail interne de spaCy. `WARNING` laisse quand même remonter un
|
|
||||||
# vrai problème (dépréciation, échec partiel) sans le bruit `INFO`.
|
|
||||||
logging.getLogger("spacy").setLevel(logging.WARNING)
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
"""Point d'entrée FastAPI — `uv run uvicorn intent_service.main:app` (voir
|
|
||||||
le Dockerfile et le README de ce service).
|
|
||||||
|
|
||||||
Le chargement des modèles spaCy de base *et* l'entraînement de chaque
|
|
||||||
locale (`PipelineRegistry.initialize`) se font dans le handler `lifespan`
|
|
||||||
ci-dessous, *avant* qu'uvicorn n'accepte de requêtes — `GET /health` ne
|
|
||||||
répond donc `200` qu'une fois ce coût payé (chargement + entraînement),
|
|
||||||
jamais pendant qu'il est encore en cours (uvicorn ne sert aucune requête
|
|
||||||
tant que le `lifespan` de démarrage n'est pas terminé). Ce service est
|
|
||||||
autonome : `training_data.TECH_STEP_TRAINING_DATA` vit dans ce module,
|
|
||||||
`apps/api` ne pousse plus rien via HTTP (voir `pipeline_registry.py` pour
|
|
||||||
le détail de ce que ça change par rapport à la version précédente).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
from .config import settings
|
|
||||||
from .logging_config import configure_logging
|
|
||||||
from .pipeline_registry import registry
|
|
||||||
from .routes import health, process
|
|
||||||
|
|
||||||
# Avant tout le reste : `routes/process.py` journalise dès la première
|
|
||||||
# requête, `initialize()` ci-dessous journalise aussi (voir
|
|
||||||
# `pipeline_registry.py`) — le formateur JSON doit déjà être en place.
|
|
||||||
configure_logging(settings.log_level)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
registry.initialize()
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="tech-step-intent-service", lifespan=lifespan)
|
|
||||||
|
|
||||||
app.include_router(health.router)
|
|
||||||
app.include_router(process.router)
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
"""Détient un `LocalePipeline` par locale supportée — le seul état mutable
|
|
||||||
partagé du process (une instance vit pour toute la durée de vie d'`uvicorn`,
|
|
||||||
montée sur `app.state`, voir `main.py`).
|
|
||||||
|
|
||||||
Volontairement une classe "registre" séparée de `LocalePipeline` lui-même :
|
|
||||||
`LocalePipeline` ne connaît qu'une seule locale, ce module route `process`
|
|
||||||
vers la bonne instance selon le `locale` reçu dans la requête — même
|
|
||||||
séparation de responsabilité que `TechStepClassifierService` (une seule
|
|
||||||
instance, un seul `NlpManager` multi-langues) avait implicitement via
|
|
||||||
node-nlp, explicitée ici puisque spaCy charge un modèle par langue.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from .locale_pipeline import SUPPORTED_LOCALES, LocalePipeline, ProcessResult, TrainEntry
|
|
||||||
from .training_data import entries_for_locale
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineRegistry:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._pipelines: dict[str, LocalePipeline] = {
|
|
||||||
locale: LocalePipeline(locale) for locale in SUPPORTED_LOCALES
|
|
||||||
}
|
|
||||||
|
|
||||||
def initialize(self) -> None:
|
|
||||||
"""Charge le modèle spaCy de base *et* entraîne chaque locale connue
|
|
||||||
depuis `training_data.TECH_STEP_TRAINING_DATA` — appelé une fois au
|
|
||||||
démarrage du process (`main.py`'s `lifespan`), avant que `uvicorn`
|
|
||||||
n'accepte de requêtes.
|
|
||||||
|
|
||||||
Contrairement à la version précédente de ce service (où `apps/api`
|
|
||||||
poussait le corpus via `POST /v1/train` à son propre warm-up), ce
|
|
||||||
service est maintenant entièrement autonome : `apps/api` ne connaît
|
|
||||||
plus aucune technique, seulement le résultat de
|
|
||||||
`POST /v1/process`. `GET /health` ne répond `200` qu'une fois cette
|
|
||||||
méthode terminée (chargement *et* entraînement) — pas seulement le
|
|
||||||
chargement — pour que `docker-compose.yml`'s `depends_on: ...
|
|
||||||
condition: service_healthy` (et la boucle d'attente équivalente en
|
|
||||||
CI) ne laisse jamais `apps/api` démarrer face à un service qui
|
|
||||||
répondrait mais ne saurait encore rien détecter.
|
|
||||||
"""
|
|
||||||
logger.info("tech-step NLP initializing pipelines", extra={"locales": list(self._pipelines)})
|
|
||||||
for locale, pipeline in self._pipelines.items():
|
|
||||||
pipeline.preload()
|
|
||||||
entries = [TrainEntry(**entry) for entry in entries_for_locale(locale)]
|
|
||||||
label_count, example_count, synonym_count = pipeline.train(entries)
|
|
||||||
logger.info(
|
|
||||||
"tech-step NLP pipeline trained",
|
|
||||||
extra={
|
|
||||||
"locale": locale,
|
|
||||||
"labelCount": label_count,
|
|
||||||
# Nombre réel d'exemples donnés au textcat (utterances
|
|
||||||
# *et* synonyms combinés — voir `LocalePipeline.train`),
|
|
||||||
# pas seulement le compte d'`utterances` du corpus.
|
|
||||||
"exampleCount": example_count,
|
|
||||||
"synonymCount": synonym_count,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
logger.info("tech-step NLP pipelines ready", extra={"locales": list(self._pipelines)})
|
|
||||||
|
|
||||||
def process(self, locale: str, text: str) -> ProcessResult:
|
|
||||||
pipeline = self._pipelines.get(locale)
|
|
||||||
if pipeline is None:
|
|
||||||
# Une locale que ce service ne sait structurellement pas
|
|
||||||
# charger (pas de modèle spaCy connu) se comporte comme une
|
|
||||||
# locale "jamais entraînée" côté `process` — reproduit le test
|
|
||||||
# `apps/api` existant ("returns an empty sequence for a locale
|
|
||||||
# nothing was trained on"), qui ne distingue pas les deux cas.
|
|
||||||
return ProcessResult(entities=[], intent=None, score=0.0)
|
|
||||||
return pipeline.process(text)
|
|
||||||
|
|
||||||
|
|
||||||
registry = PipelineRegistry()
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
"""`GET /health` — sondé par le `healthcheck` Docker (`docker-compose.yml`)
|
|
||||||
et par l'étape CI qui attend que ce service soit prêt avant de lancer la
|
|
||||||
suite Mocha de `apps/api` (voir `.github/workflows/ci.yml`). Volontairement
|
|
||||||
sans authentification, même posture que le `GET /health` existant côté
|
|
||||||
`apps/api` (`app.ts`) — un healthcheck qui exigerait un secret compliquerait
|
|
||||||
sa configuration pour un gain de sécurité nul (il ne renvoie aucune donnée).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
from ..schemas import HealthResponse
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health", response_model=HealthResponse)
|
|
||||||
def health() -> HealthResponse:
|
|
||||||
return HealthResponse(status="ok")
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
"""`POST /v1/process` — appelé par `apps/api` (`IntentServiceClient.process`)
|
|
||||||
en remplacement direct de l'ancien `NlpManager.process(locale, text)`. Voir
|
|
||||||
`LocalePipeline.process` pour la sémantique exacte (locale non entraînée ou
|
|
||||||
`text` vide -> résultat vide, jamais une erreur).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from ..pipeline_registry import registry
|
|
||||||
from ..schemas import EntityPayload, ProcessRequest, ProcessResponse
|
|
||||||
from ..security import require_valid_secret
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(require_valid_secret)])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/v1/process", response_model=ProcessResponse)
|
|
||||||
def process(request: ProcessRequest) -> ProcessResponse:
|
|
||||||
result = registry.process(request.locale, request.text)
|
|
||||||
|
|
||||||
# Une ligne par appel — input (`locale`/`text`) et output (`entities`/
|
|
||||||
# `intent`/`score`) réunis dans la même ligne JSON, pour pouvoir suivre
|
|
||||||
# exactement ce que le pipeline a décidé pour un texte donné (voir
|
|
||||||
# `logging_config.py` pour le format).
|
|
||||||
logger.info(
|
|
||||||
"tech-step NLP process",
|
|
||||||
extra={
|
|
||||||
"locale": request.locale,
|
|
||||||
"text": request.text,
|
|
||||||
"entities": [
|
|
||||||
{"uid": entity.uid, "start": entity.start, "end": entity.end, "kind": entity.kind}
|
|
||||||
for entity in result.entities
|
|
||||||
],
|
|
||||||
"intent": result.intent,
|
|
||||||
"score": result.score,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return ProcessResponse(
|
|
||||||
entities=[
|
|
||||||
EntityPayload(uid=entity.uid, start=entity.start, end=entity.end, kind=entity.kind)
|
|
||||||
for entity in result.entities
|
|
||||||
],
|
|
||||||
intent=result.intent,
|
|
||||||
score=result.score,
|
|
||||||
)
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
"""Modèles Pydantic du contrat HTTP — voir le plan de migration pour le
|
|
||||||
contrat exact attendu côté `apps/api` (`IntentServiceClient`,
|
|
||||||
`apps/api/src/lib/recipe-matching/intent-service-client.ts`).
|
|
||||||
|
|
||||||
Pas de `POST /v1/train` ici — ce service s'entraîne lui-même au démarrage
|
|
||||||
depuis `training_data.py` (voir `pipeline_registry.py`/`main.py`), plus
|
|
||||||
besoin d'un contrat HTTP pour ça.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# POST /v1/process
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessRequest(BaseModel):
|
|
||||||
locale: str
|
|
||||||
text: str
|
|
||||||
|
|
||||||
|
|
||||||
class EntityPayload(BaseModel):
|
|
||||||
"""Une mention candidate — technique ou ustensile, voir `kind` — offsets
|
|
||||||
caractère `[start, end)` dans `text`, convention identique à
|
|
||||||
`String.prototype.slice` côté `apps/api` (pas de décalage `+1` à
|
|
||||||
appliquer côté Node, contrairement à l'ancien `NlpManager` de
|
|
||||||
node-nlp).
|
|
||||||
|
|
||||||
`kind` distingue de quel `PhraseMatcher` la mention vient (voir
|
|
||||||
`locale_pipeline.py`'s `Entity`) — `apps/api`'s `tech-step-matcher.ts`
|
|
||||||
en a besoin pour savoir laquelle des deux résoudre (`TechStep.key` vs
|
|
||||||
`Utensil.key`)."""
|
|
||||||
|
|
||||||
uid: str
|
|
||||||
start: int
|
|
||||||
end: int
|
|
||||||
kind: Literal["technique", "utensil"] = "technique"
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessResponse(BaseModel):
|
|
||||||
entities: list[EntityPayload]
|
|
||||||
intent: str | None
|
|
||||||
score: float
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# GET /health
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(BaseModel):
|
|
||||||
status: str
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
"""Authentification des appels entrants — miroir inversé de `requireInternalWorker`
|
|
||||||
(`apps/api/src/middlewares/require-internal-worker.ts`) : ici c'est
|
|
||||||
`apps/api` qui appelle *ce* service, donc c'est ce service qui vérifie le
|
|
||||||
secret plutôt que de l'envoyer.
|
|
||||||
|
|
||||||
Comparaison à temps constant (`hmac.compare_digest`, l'équivalent Python du
|
|
||||||
`timingSafeEqual` de Node utilisé côté `apps/api`) — même raisonnement :
|
|
||||||
un attaquant ne doit rien apprendre de la durée de la comparaison au-delà de
|
|
||||||
ce qu'une différence de longueur révèle déjà.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import hmac
|
|
||||||
|
|
||||||
from fastapi import Header, HTTPException, status
|
|
||||||
|
|
||||||
from .config import settings
|
|
||||||
|
|
||||||
_SECRET_HEADER_NAME = "x-intent-service-secret"
|
|
||||||
|
|
||||||
|
|
||||||
def require_valid_secret(
|
|
||||||
x_intent_service_secret: str | None = Header(default=None, alias=_SECRET_HEADER_NAME),
|
|
||||||
) -> None:
|
|
||||||
"""Dépendance FastAPI montée sur chaque route protégée (`/v1/*`) — pas
|
|
||||||
`GET /health`, sondé par le healthcheck Docker sans configuration
|
|
||||||
d'auth propre.
|
|
||||||
|
|
||||||
`settings.intent_service_secret` est garanti non vide par `config.py`
|
|
||||||
(pas de valeur par défaut dans `Settings`) — le seul cas à traiter ici
|
|
||||||
est un header manquant ou incorrect côté appelant.
|
|
||||||
"""
|
|
||||||
if x_intent_service_secret is None or not hmac.compare_digest(
|
|
||||||
x_intent_service_secret, settings.intent_service_secret
|
|
||||||
):
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
"""Port Python de `normalizeText` (`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`).
|
|
||||||
|
|
||||||
Doit rester bit-pour-bit équivalent à sa contrepartie TypeScript — c'est ce
|
|
||||||
qui garantit qu'un synonyme matché ici tombe exactement sur les mêmes
|
|
||||||
positions caractère que ce que `apps/api` attendait de node-nlp (voir
|
|
||||||
`LocalePipeline`'s `diacritics_normalizer`, qui applique cette fonction aux
|
|
||||||
patterns *et* au texte cible pour les faire matcher identiquement).
|
|
||||||
|
|
||||||
TypeScript original :
|
|
||||||
|
|
||||||
const COMBINING_DIACRITICS_PATTERN = /\\p{Diacritic}/gu;
|
|
||||||
export function normalizeText(text: string): string {
|
|
||||||
return text.normalize("NFD").replace(COMBINING_DIACRITICS_PATTERN, "").toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
`unicodedata.combining(ch) != 0` (catégories Unicode Mn/Mc, la classe de
|
|
||||||
combinaison canonique) est l'idiome Python standard pour "strip accents
|
|
||||||
after NFD" — légèrement plus étroit que `\\p{Diacritic}` en théorie (qui
|
|
||||||
couvre aussi quelques diacritiques autonomes hors caractères combinants),
|
|
||||||
mais strictement équivalent pour tout caractère latin accentué usuel
|
|
||||||
(français/anglais) une fois décomposé en NFD, ce qui est le seul cas
|
|
||||||
réellement exercé par ce corpus.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import unicodedata
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_text(text: str) -> str:
|
|
||||||
"""Décompose en NFD, retire les marques combinantes (accents), met en minuscule."""
|
|
||||||
decomposed = unicodedata.normalize("NFD", text)
|
|
||||||
stripped = "".join(char for char in decomposed if not unicodedata.combining(char))
|
|
||||||
return stripped.lower()
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,197 +0,0 @@
|
||||||
"""Vocabulaire du `PhraseMatcher` d'ustensiles — contrairement à
|
|
||||||
`training_data.py`, ce catalogue n'a jamais existé côté `apps/api` avant ce
|
|
||||||
service : il est *né* ici, pas rapatrié depuis TypeScript. Chaque `uid`
|
|
||||||
ci-dessous doit avoir une entrée `UTENSILS` correspondante
|
|
||||||
(`reference-seed-data.ts` côté `apps/api`) et un libellé
|
|
||||||
`catalog.utensils.<uid>` (`apps/web`'s `locales/fr/translation.json`).
|
|
||||||
|
|
||||||
Un seul type de contenu par ustensile/locale (contrairement à
|
|
||||||
`TechStepTrainingEntry`'s `synonyms`/`utterances`) : un ustensile mentionné
|
|
||||||
n'a pas besoin d'être *interprété* comme une technique peut l'être
|
|
||||||
(`préchauffer` vs `chauffer` dépend du contexte ; `poêle` n'en dépend pas) —
|
|
||||||
juste reconnu, comme les `synonyms` de `training_data.py` alimentent le
|
|
||||||
`PhraseMatcher` de techniques. Pas de `textcat` équivalent ici, voir
|
|
||||||
`LocalePipeline`'s propre commentaire sur `_utensil_matcher`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class UtensilLocaleVocabulary:
|
|
||||||
synonyms: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class UtensilEntry:
|
|
||||||
"""`uid` doit correspondre à un `Utensil.key`."""
|
|
||||||
|
|
||||||
uid: str
|
|
||||||
fr: UtensilLocaleVocabulary
|
|
||||||
en: UtensilLocaleVocabulary
|
|
||||||
|
|
||||||
|
|
||||||
UTENSIL_VOCABULARY: list[UtensilEntry] = [
|
|
||||||
UtensilEntry(
|
|
||||||
uid="pan",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["poêle", "sauteuse", "poêle antiadhésive"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["pan", "frying pan", "skillet"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="saucepan",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["casserole", "petite casserole"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["saucepan", "sauce pan"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="pot",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["marmite", "faitout", "cocotte"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["pot", "stockpot", "dutch oven"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="knife",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["couteau", "couteau de cuisine", "couteau d'office"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["knife", "kitchen knife", "chef's knife"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="whisk",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["fouet"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["whisk"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="bowl",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["saladier", "bol", "cul-de-poule"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["bowl", "mixing bowl"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="bakingSheet",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["plaque de cuisson", "plaque à pâtisserie", "plaque du four"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["baking sheet", "baking tray", "sheet pan"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="mold",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["moule", "moule à gâteau", "moule à cake"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["mold", "mould", "baking pan"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="colander",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["passoire", "égouttoir"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["colander", "strainer"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="cuttingBoard",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["planche à découper"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["cutting board", "chopping board"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="oven",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["four"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["oven"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="blender",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["blender", "mixeur plongeant", "mixeur girafe"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["blender", "immersion blender"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="mixer",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["batteur", "batteur électrique", "robot pâtissier"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["mixer", "stand mixer", "hand mixer"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="spatula",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["spatule", "maryse"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["spatula"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="ladle",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["louche"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["ladle"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="grater",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["râpe"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["grater"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="rollingPin",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["rouleau à pâtisserie"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["rolling pin"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="lid",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["couvercle"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["lid"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="tongs",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["pince", "pince de cuisine"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["tongs"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="peeler",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["économe", "éplucheur"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["peeler", "vegetable peeler"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="sieve",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["tamis", "chinois"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["sieve"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="foodProcessor",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["robot ménager", "robot de cuisine", "robot culinaire"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["food processor"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="steamerBasket",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["panier vapeur", "cuit-vapeur"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["steamer basket", "steamer"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="skewer",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["brochette", "pique en bois"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["skewer"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="pastryBrush",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["pinceau de cuisine", "pinceau à pâtisserie"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["pastry brush", "basting brush"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="ramekin",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["ramequin"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["ramekin"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="dish",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["plat", "plat à gratin", "plat allant au four"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["dish", "baking dish", "gratin dish"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="wok",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["wok"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["wok"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="thermometer",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["thermomètre", "thermomètre de cuisson"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["thermometer"]),
|
|
||||||
),
|
|
||||||
UtensilEntry(
|
|
||||||
uid="mandoline",
|
|
||||||
fr=UtensilLocaleVocabulary(synonyms=["mandoline"]),
|
|
||||||
en=UtensilLocaleVocabulary(synonyms=["mandoline"]),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def synonyms_for_locale(locale: str) -> dict[str, list[str]]:
|
|
||||||
"""Aplati {@link UTENSIL_VOCABULARY} en `{uid: synonyms}` pour une seule
|
|
||||||
locale — la forme que `LocalePipeline.preload()` attend pour construire
|
|
||||||
son `PhraseMatcher` d'ustensiles. Miroir de `training_data.entries_for_locale`,
|
|
||||||
en plus simple (pas d'`utterances`, un seul champ à extraire)."""
|
|
||||||
return {
|
|
||||||
entry.uid: getattr(entry, locale).synonyms
|
|
||||||
for entry in UTENSIL_VOCABULARY
|
|
||||||
if hasattr(entry, locale)
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
[project]
|
|
||||||
name = "tech-step-intent-service"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Microservice de détection d'intention (technique de cuisine) — remplace node-nlp côté apps/api."
|
|
||||||
requires-python = ">=3.12"
|
|
||||||
dependencies = [
|
|
||||||
"fastapi>=0.115,<0.116",
|
|
||||||
"uvicorn[standard]>=0.32,<0.33",
|
|
||||||
"spacy>=3.8,<3.9",
|
|
||||||
# Fournit les tables de lookup ("lexeme_norm" notamment) que
|
|
||||||
# `nlp.initialize()` réclame pour l'anglais lors de l'entraînement du
|
|
||||||
# textcat (`en_core_web_md` ne les embarque pas lui-même, contrairement à
|
|
||||||
# `fr_core_news_md`) — sans ce paquet, entraîner un pipeline "en" lève
|
|
||||||
# `E955`.
|
|
||||||
"spacy-lookups-data>=1.0,<1.1",
|
|
||||||
"pydantic-settings>=2.6,<3",
|
|
||||||
# Modèles spaCy installés comme des dépendances pip normales, pinnées par
|
|
||||||
# URL de release GitHub (pas via `python -m spacy download`, qui résout
|
|
||||||
# "la dernière version compatible" et n'est pas verrouillable par
|
|
||||||
# `uv.lock`). `uv sync --frozen` installe donc déjà les modèles — aucune
|
|
||||||
# étape `spacy download` séparée, ni au Dockerfile ni en CI. Version
|
|
||||||
# 3.8.0 choisie pour matcher la ligne spaCy 3.8 pinnée ci-dessus (voir
|
|
||||||
# https://github.com/explosion/spacy-models/releases).
|
|
||||||
"fr_core_news_md @ https://github.com/explosion/spacy-models/releases/download/fr_core_news_md-3.8.0/fr_core_news_md-3.8.0-py3-none-any.whl",
|
|
||||||
"en_core_web_md @ https://github.com/explosion/spacy-models/releases/download/en_core_web_md-3.8.0/en_core_web_md-3.8.0-py3-none-any.whl",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependency-groups]
|
|
||||||
dev = [
|
|
||||||
"pytest>=8,<9",
|
|
||||||
# Requis par fastapi.testclient.TestClient (httpx en interne depuis FastAPI 0.110+).
|
|
||||||
"httpx>=0.27,<0.28",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.uv]
|
|
||||||
# Les deux modèles ci-dessus sont publiés comme des builds "any" universels
|
|
||||||
# (pas de wheel spécifique par plateforme) — rien à déclarer de plus ici,
|
|
||||||
# contrairement à un paquet avec des extras natifs par OS/arch.
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["hatchling"]
|
|
||||||
build-backend = "hatchling.build"
|
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
|
||||||
packages = ["intent_service"]
|
|
||||||
|
|
||||||
[tool.hatch.metadata]
|
|
||||||
# Requis par hatchling pour accepter des dépendances pinnées par URL directe
|
|
||||||
# (les wheels de modèles spaCy ci-dessus) plutôt qu'un nom+version résolu
|
|
||||||
# depuis un index PyPI — voir la note sur `pyproject.toml` dans le plan de
|
|
||||||
# migration pour pourquoi ces modèles sont déclarés ainsi plutôt que via
|
|
||||||
# `python -m spacy download`.
|
|
||||||
allow-direct-references = true
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
"""`Settings` (`intent_service/config.py`) lève dès l'import si
|
|
||||||
`INTENT_SERVICE_SECRET` est absent — cette variable doit donc être définie
|
|
||||||
avant le tout premier `import intent_service...` de la session pytest.
|
|
||||||
`conftest.py` est chargé par pytest avant la collecte des modules de test,
|
|
||||||
donc avant que `test_routes_process.py`/`test_security.py` n'importent
|
|
||||||
`intent_service.main`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
os.environ.setdefault("INTENT_SERVICE_SECRET", "pytest-only-secret-not-used-anywhere-else-32ch")
|
|
||||||
|
|
||||||
import pytest # noqa: E402 — après le `setdefault` ci-dessus, voir le docstring.
|
|
||||||
from fastapi.testclient import TestClient # noqa: E402
|
|
||||||
|
|
||||||
from intent_service.main import app # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def client():
|
|
||||||
"""`TestClient(app)` utilisé comme gestionnaire de contexte déclenche le
|
|
||||||
vrai `lifespan` — puisque `main.py`'s `lifespan` entraîne maintenant
|
|
||||||
l'intégralité du vrai corpus `training_data.TECH_STEP_TRAINING_DATA`
|
|
||||||
(pas un jeu jouet, voir `PipelineRegistry.initialize`), refaire ça une
|
|
||||||
fois par fichier de test (ou pire, une fois par test) multiplierait un
|
|
||||||
entraînement non négligeable sur toute la suite pour rien — scope
|
|
||||||
"session" pour que chaque test ayant besoin d'une vraie app en cours
|
|
||||||
d'exécution partage la même instance déjà entraînée.
|
|
||||||
"""
|
|
||||||
with TestClient(app) as test_client:
|
|
||||||
yield test_client
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
"""Rejoue les cas d'offsets caractère exacts et d'insensibilité accents/casse
|
|
||||||
de `tech-step-matcher.test.ts` (`apps/api/test/recipe-matching/tech-step-matcher.test.ts`)
|
|
||||||
contre le `PhraseMatcher`/`diacritics_normalizer` de `LocalePipeline` — le
|
|
||||||
point de fidélité le plus critique de cette migration (voir le plan). Doit
|
|
||||||
être vert *avant* de brancher `apps/api` dessus.
|
|
||||||
|
|
||||||
Ces tests entraînent un pipeline minimal (pas le corpus complet
|
|
||||||
`TECH_STEP_TRAINING_DATA`, propriété de `apps/api`) avec juste assez de
|
|
||||||
`synonyms`/`utterances` pour reproduire chaque cas — le textcat n'est pas ce
|
|
||||||
qui est vérifié ici (voir `test_locale_pipeline_intent.py`).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from intent_service.locale_pipeline import LocalePipeline, TrainEntry
|
|
||||||
|
|
||||||
# Un jeu d'entrées minimal mais réaliste, reprenant les synonymes réels de
|
|
||||||
# `tech-step-training-data.ts` pour "preheat"/"melt" qui rendent les cas
|
|
||||||
# `tech-step-matcher.test.ts` exacts (voir ce fichier, lignes 155/787).
|
|
||||||
_FR_ENTRIES = [
|
|
||||||
TrainEntry(
|
|
||||||
uid="preheat",
|
|
||||||
synonyms=["préchauffer", "poêle chaude"],
|
|
||||||
utterances=["préchauffer le four à 180 degrés", "mettre la poêle sur feu vif"],
|
|
||||||
),
|
|
||||||
TrainEntry(
|
|
||||||
# `synonyms` deliberately includes both "fondre" (standalone) and
|
|
||||||
# "faire fondre" (containing it) — mirrors the real corpus
|
|
||||||
# (`tech-step-training-data.ts`) exactly, and is what
|
|
||||||
# `test_does_not_double_match_a_synonym_nested_in_a_longer_one`
|
|
||||||
# below exists to guard: the `PhraseMatcher` reports both as
|
|
||||||
# separate overlapping matches, `LocalePipeline.process` must
|
|
||||||
# collapse them into one.
|
|
||||||
uid="melt",
|
|
||||||
synonyms=["fondre", "fondu", "faire fondre", "faire chauffer"],
|
|
||||||
utterances=["faire fondre le beurre", "faire chauffer une noix de beurre"],
|
|
||||||
),
|
|
||||||
TrainEntry(
|
|
||||||
uid="simmer",
|
|
||||||
synonyms=["mijoter"],
|
|
||||||
utterances=["faire mijoter à feu doux", "laisser mijoter à couvert"],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def fr_pipeline() -> LocalePipeline:
|
|
||||||
pipeline = LocalePipeline("fr")
|
|
||||||
pipeline.train(_FR_ENTRIES)
|
|
||||||
return pipeline
|
|
||||||
|
|
||||||
|
|
||||||
def test_matches_an_exact_expression(fr_pipeline: LocalePipeline):
|
|
||||||
result = fr_pipeline.process("Faire mijoter à feu doux")
|
|
||||||
assert [entity.uid for entity in result.entities] == ["simmer"]
|
|
||||||
entity = result.entities[0]
|
|
||||||
text = "Faire mijoter à feu doux"
|
|
||||||
assert text[entity.start : entity.end].lower() == "mijoter"
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_case_and_accent_insensitive(fr_pipeline: LocalePipeline):
|
|
||||||
result = fr_pipeline.process("FAIRE MIJOTER")
|
|
||||||
assert [entity.uid for entity in result.entities] == ["simmer"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_returns_no_entities_when_nothing_matches(fr_pipeline: LocalePipeline):
|
|
||||||
result = fr_pipeline.process("Ranger les couverts dans le tiroir")
|
|
||||||
assert result.entities == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_returns_empty_for_an_empty_text(fr_pipeline: LocalePipeline):
|
|
||||||
result = fr_pipeline.process("")
|
|
||||||
assert result.entities == []
|
|
||||||
assert result.intent is None
|
|
||||||
assert result.score == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_untrained_locale_returns_empty_without_error():
|
|
||||||
pipeline = LocalePipeline("en")
|
|
||||||
result = pipeline.process("melt the butter")
|
|
||||||
assert result.entities == []
|
|
||||||
assert result.intent is None
|
|
||||||
assert result.score == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_detects_two_techniques_with_exact_tight_spans_reading_order(fr_pipeline: LocalePipeline):
|
|
||||||
# This text's "poêle" is now *also* a real utensil match ("pan", see
|
|
||||||
# `utensil_vocabulary.py`) — filtered out here by `kind` since this test
|
|
||||||
# is specifically about technique-candidate ordering, not the full
|
|
||||||
# mixed entity list (see `test_utensil_matching.py` for the utensil
|
|
||||||
# matcher's own coverage).
|
|
||||||
text = "Préchauffer la poêle, puis faire fondre le beurre"
|
|
||||||
result = fr_pipeline.process(text)
|
|
||||||
|
|
||||||
technique_entities = [entity for entity in result.entities if entity.kind == "technique"]
|
|
||||||
uids_by_start = sorted(((entity.start, entity.uid) for entity in technique_entities))
|
|
||||||
assert [uid for _, uid in uids_by_start] == ["preheat", "melt"]
|
|
||||||
|
|
||||||
preheat_entity = next(e for e in result.entities if e.uid == "preheat")
|
|
||||||
melt_entity = next(e for e in result.entities if e.uid == "melt")
|
|
||||||
assert text[preheat_entity.start : preheat_entity.end].lower() == "préchauffer"
|
|
||||||
assert text[melt_entity.start : melt_entity.end].lower() == "faire fondre"
|
|
||||||
|
|
||||||
|
|
||||||
def test_does_not_double_match_a_synonym_nested_in_a_longer_one(fr_pipeline: LocalePipeline):
|
|
||||||
# Regression: "fondre" is itself a substring of "faire fondre" — both
|
|
||||||
# are registered as `melt` synonyms (like the real corpus). Without
|
|
||||||
# `filter_spans` in `LocalePipeline.process`, the `PhraseMatcher`
|
|
||||||
# reports *both* overlapping matches, producing `melt` twice in
|
|
||||||
# apps/api's final `matchTechSteps` output instead of once (caught by a
|
|
||||||
# real CI failure in `tech-step-matcher.test.ts` once this service
|
|
||||||
# replaced node-nlp).
|
|
||||||
text = "faire fondre le beurre"
|
|
||||||
result = fr_pipeline.process(text)
|
|
||||||
assert [entity.uid for entity in result.entities] == ["melt"]
|
|
||||||
entity = result.entities[0]
|
|
||||||
assert text[entity.start : entity.end] == "faire fondre"
|
|
||||||
|
|
||||||
|
|
||||||
def test_matches_the_classic_poele_chaude_example_with_exact_offsets(fr_pipeline: LocalePipeline):
|
|
||||||
# Le cas motivant les context spans côté apps/api (tech-step-matcher.test.ts) :
|
|
||||||
# le mot-clé de `preheat` est un groupe nominal ("poêle chaude"), pas un
|
|
||||||
# verbe. Offsets attendus IDENTIQUES à ceux du test TS d'origine :
|
|
||||||
# preheat -> [9, 21) ("poêle chaude"), melt -> [23, 37) ("faire chauffer").
|
|
||||||
text = "Dans une poêle chaude, faire chauffer une noix de beurre"
|
|
||||||
result = fr_pipeline.process(text)
|
|
||||||
|
|
||||||
preheat_entity = next(e for e in result.entities if e.uid == "preheat")
|
|
||||||
melt_entity = next(e for e in result.entities if e.uid == "melt")
|
|
||||||
|
|
||||||
assert (preheat_entity.start, preheat_entity.end) == (9, 21)
|
|
||||||
assert text[preheat_entity.start : preheat_entity.end] == "poêle chaude"
|
|
||||||
|
|
||||||
assert (melt_entity.start, melt_entity.end) == (23, 37)
|
|
||||||
assert text[melt_entity.start : melt_entity.end] == "faire chauffer"
|
|
||||||
|
|
||||||
|
|
||||||
def test_chop_matches_english_text_tight_span():
|
|
||||||
pipeline = LocalePipeline("en")
|
|
||||||
pipeline.train(
|
|
||||||
[
|
|
||||||
TrainEntry(
|
|
||||||
uid="chop",
|
|
||||||
synonyms=["chop"],
|
|
||||||
utterances=["chop the onions finely", "finely chop the garlic"],
|
|
||||||
),
|
|
||||||
TrainEntry(uid="boil", synonyms=["boil"], utterances=["bring to the boil", "boil the water"]),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
text = "Chop the onions finely"
|
|
||||||
result = pipeline.process(text)
|
|
||||||
chop_entity = next(e for e in result.entities if e.uid == "chop")
|
|
||||||
assert (chop_entity.start, chop_entity.end) == (0, 4)
|
|
||||||
assert text[chop_entity.start : chop_entity.end] == "Chop"
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
"""Vérifie le round-trip entraînement -> prédiction du `textcat` (la partie
|
|
||||||
"comprendre le sens, pas juste les mots clés" du pipeline — voir le
|
|
||||||
commentaire de `tech-step-matcher.ts` côté apps/api pour la motivation
|
|
||||||
d'origine)."""
|
|
||||||
|
|
||||||
from intent_service.locale_pipeline import LocalePipeline, TrainEntry
|
|
||||||
|
|
||||||
_ENTRIES = [
|
|
||||||
TrainEntry(
|
|
||||||
uid="melt",
|
|
||||||
synonyms=["faire fondre"],
|
|
||||||
utterances=[
|
|
||||||
"faire fondre le beurre à feu doux",
|
|
||||||
"laisser fondre le beurre dans la poêle",
|
|
||||||
"jusqu'à ce que le beurre ait disparu",
|
|
||||||
"jusqu'à ce que le beurre ait complètement disparu dans la poêle",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
TrainEntry(
|
|
||||||
uid="boil",
|
|
||||||
synonyms=["bouillir"],
|
|
||||||
utterances=[
|
|
||||||
"porter l'eau à ébullition",
|
|
||||||
"faire bouillir l'eau salée",
|
|
||||||
"laisser bouillir quelques minutes",
|
|
||||||
"porter à ébullition puis baisser le feu",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_train_returns_label_example_and_synonym_counts():
|
|
||||||
pipeline = LocalePipeline("fr")
|
|
||||||
label_count, example_count, synonym_count = pipeline.train(_ENTRIES)
|
|
||||||
assert label_count == 2
|
|
||||||
# `example_count` couvre les utterances *et* les synonyms (voir
|
|
||||||
# LocalePipeline.train — les synonymes sont aussi des exemples
|
|
||||||
# d'entraînement pour le textcat, pas seulement pour le PhraseMatcher).
|
|
||||||
expected_examples = sum(len(entry.utterances) + len(entry.synonyms) for entry in _ENTRIES)
|
|
||||||
assert example_count == expected_examples
|
|
||||||
assert synonym_count == sum(len(entry.synonyms) for entry in _ENTRIES)
|
|
||||||
assert pipeline.is_trained is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_classifies_a_paraphrase_never_using_the_techniques_own_verb():
|
|
||||||
# Le cas motivant tout le pipeline (voir tech-step-matcher.ts) : aucune
|
|
||||||
# forme de "fondre" dans cette phrase, mais elle ne peut raisonnablement
|
|
||||||
# signifier que `melt` une fois le textcat entraîné sur les paraphrases
|
|
||||||
# ci-dessus.
|
|
||||||
pipeline = LocalePipeline("fr")
|
|
||||||
pipeline.train(_ENTRIES)
|
|
||||||
|
|
||||||
result = pipeline.process("jusqu'à ce que le beurre ait disparu dans la poêle")
|
|
||||||
assert result.intent == "melt"
|
|
||||||
assert result.score > 0.5
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_entries_leaves_the_pipeline_untrained():
|
|
||||||
pipeline = LocalePipeline("fr")
|
|
||||||
pipeline.train([])
|
|
||||||
assert pipeline.is_trained is False
|
|
||||||
result = pipeline.process("faire fondre le beurre")
|
|
||||||
assert result.intent is None
|
|
||||||
assert result.entities == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_retraining_replaces_the_previous_textcat_rather_than_accumulating():
|
|
||||||
# `textcat` (exclusive_classes) exige >= 2 labels (voir la note dans
|
|
||||||
# LocalePipeline.train) — le second entraînement garde donc 2 entrées,
|
|
||||||
# mais remplace "boil" par une technique différente ("chop"), pour
|
|
||||||
# vérifier que "boil" ne peut plus jamais ressortir après coup (pas de
|
|
||||||
# fusion incrémentale — voir la doc de `LocalePipeline.train`).
|
|
||||||
pipeline = LocalePipeline("fr")
|
|
||||||
pipeline.train(_ENTRIES)
|
|
||||||
|
|
||||||
chop_entry = TrainEntry(uid="chop", synonyms=["couper"], utterances=["couper les légumes en dés"])
|
|
||||||
pipeline.train([_ENTRIES[0], chop_entry])
|
|
||||||
|
|
||||||
result = pipeline.process("porter l'eau à ébullition")
|
|
||||||
assert result.intent != "boil"
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
"""Vérifie le format des lignes de log produites par
|
|
||||||
`logging_config._JsonFormatter` — ce que `routes/process.py` et
|
|
||||||
`pipeline_registry.py` utilisent pour journaliser l'input/l'output de
|
|
||||||
chaque appel NLP et le déroulement de l'entraînement au démarrage."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from intent_service.logging_config import _JsonFormatter
|
|
||||||
|
|
||||||
|
|
||||||
def _make_record(**extra: object) -> logging.LogRecord:
|
|
||||||
record = logging.LogRecord(
|
|
||||||
name="intent_service.routes.process",
|
|
||||||
level=logging.INFO,
|
|
||||||
pathname=__file__,
|
|
||||||
lineno=1,
|
|
||||||
msg="tech-step NLP process",
|
|
||||||
args=(),
|
|
||||||
exc_info=None,
|
|
||||||
)
|
|
||||||
for key, value in extra.items():
|
|
||||||
setattr(record, key, value)
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
def test_formats_a_record_as_json_with_timestamp_level_and_message():
|
|
||||||
record = _make_record()
|
|
||||||
payload = json.loads(_JsonFormatter().format(record))
|
|
||||||
assert payload["message"] == "tech-step NLP process"
|
|
||||||
assert payload["level"] == "info"
|
|
||||||
assert "timestamp" in payload
|
|
||||||
|
|
||||||
|
|
||||||
def test_merges_extra_fields_into_the_top_level_payload():
|
|
||||||
record = _make_record(
|
|
||||||
locale="fr",
|
|
||||||
text="faire fondre le beurre",
|
|
||||||
entities=[{"uid": "melt", "start": 0, "end": 12}],
|
|
||||||
intent="melt",
|
|
||||||
score=0.93,
|
|
||||||
)
|
|
||||||
payload = json.loads(_JsonFormatter().format(record))
|
|
||||||
assert payload["locale"] == "fr"
|
|
||||||
assert payload["text"] == "faire fondre le beurre"
|
|
||||||
assert payload["entities"] == [{"uid": "melt", "start": 0, "end": 12}]
|
|
||||||
assert payload["intent"] == "melt"
|
|
||||||
assert payload["score"] == 0.93
|
|
||||||
|
|
||||||
|
|
||||||
def test_preserves_accented_characters_literally_not_escaped():
|
|
||||||
# `ensure_ascii=False` — un `docker logs` humain doit pouvoir lire
|
|
||||||
# directement "poêle", pas "poêle".
|
|
||||||
record = _make_record(text="Dans une poêle chaude")
|
|
||||||
line = _JsonFormatter().format(record)
|
|
||||||
assert "poêle" in line
|
|
||||||
assert "\\u00ea" not in line
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
"""Contrat JSON de `POST /v1/process` — voir `schemas.py`/`routes/process.py`.
|
|
||||||
|
|
||||||
Le service s'entraîne désormais lui-même au démarrage sur le vrai corpus
|
|
||||||
(`training_data.TECH_STEP_TRAINING_DATA`, voir `conftest.py`'s fixture
|
|
||||||
`client` partagée) — ces tests vérifient donc le contrat HTTP contre des
|
|
||||||
phrases réelles du corpus, plus besoin d'un `POST /v1/train` préalable avec
|
|
||||||
des données jouets.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from intent_service.config import settings
|
|
||||||
|
|
||||||
_HEADERS = {"X-Intent-Service-Secret": settings.intent_service_secret}
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_against_an_unsupported_locale_returns_empty_result(client: TestClient):
|
|
||||||
# "de" n'a aucun modèle spaCy connu (`SUPPORTED_LOCALES`) — se comporte
|
|
||||||
# comme "jamais entraîné" côté `/v1/process`, jamais une erreur (voir
|
|
||||||
# `PipelineRegistry.process`).
|
|
||||||
response = client.post(
|
|
||||||
"/v1/process", headers=_HEADERS, json={"locale": "de", "text": "faire mijoter à feu doux"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"entities": [], "intent": None, "score": 0.0}
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_returns_entities_and_intent_for_a_real_corpus_sentence(client: TestClient):
|
|
||||||
response = client.post(
|
|
||||||
"/v1/process", headers=_HEADERS, json={"locale": "fr", "text": "Faire mijoter à feu doux"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
body = response.json()
|
|
||||||
assert body["intent"] == "simmer"
|
|
||||||
assert body["score"] > 0
|
|
||||||
assert [entity["uid"] for entity in body["entities"]] == ["simmer"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_matches_english_text_against_the_english_trained_vocabulary(client: TestClient):
|
|
||||||
response = client.post(
|
|
||||||
"/v1/process", headers=_HEADERS, json={"locale": "en", "text": "Chop the onions finely"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
body = response.json()
|
|
||||||
assert [entity["uid"] for entity in body["entities"]] == ["chop"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_with_blank_text_returns_empty_result(client: TestClient):
|
|
||||||
response = client.post("/v1/process", headers=_HEADERS, json={"locale": "fr", "text": " "})
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"entities": [], "intent": None, "score": 0.0}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
"""`require_valid_secret` — miroir inversé de
|
|
||||||
`require-internal-worker.test.ts` côté `apps/api`. Utilise la fixture
|
|
||||||
`client` partagée (`conftest.py`) — pas besoin d'une app entraînée
|
|
||||||
séparément juste pour tester l'authentification.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from intent_service.config import settings
|
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_a_missing_secret(client: TestClient):
|
|
||||||
response = client.post("/v1/process", json={"locale": "fr", "text": "faire fondre"})
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_a_wrong_secret(client: TestClient):
|
|
||||||
response = client.post(
|
|
||||||
"/v1/process",
|
|
||||||
json={"locale": "fr", "text": "faire fondre"},
|
|
||||||
headers={"X-Intent-Service-Secret": "not-the-right-secret"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_accepts_the_configured_secret(client: TestClient):
|
|
||||||
response = client.post(
|
|
||||||
"/v1/process",
|
|
||||||
json={"locale": "fr", "text": "faire fondre"},
|
|
||||||
headers={"X-Intent-Service-Secret": settings.intent_service_secret},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_health_requires_no_secret(client: TestClient):
|
|
||||||
response = client.get("/health")
|
|
||||||
assert response.status_code == 200
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue