Compare commits
14 commits
feat/shopp
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f7aa696f55 | |||
| f19365e20e | |||
| 8a51f0bd6c | |||
| 32c7ed48c6 | |||
| 0ab30a4588 | |||
| 3c04efd16f | |||
| 7c423cc0fd | |||
| d58491e6f9 | |||
| 478914787f | |||
| 5ab9832131 | |||
|
|
eef5db92b5 | ||
|
|
550627919d | ||
|
|
bf58834aa9 | ||
|
|
109dde9c7b |
104 changed files with 10502 additions and 2479 deletions
|
|
@ -22,6 +22,14 @@ 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
|
||||||
|
|
|
||||||
84
.github/workflows/ci.yml
vendored
84
.github/workflows/ci.yml
vendored
|
|
@ -12,26 +12,32 @@ on:
|
||||||
push:
|
push:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public"
|
DATABASE_URL: "postgresql://ci:ci@postgres:5432/batchcooking_ci?schema=public"
|
||||||
# Test-only secret, never used outside CI — real deployments must set their own.
|
# Test-only secret, never used outside CI — real deployments must set their own.
|
||||||
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
|
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
|
||||||
# Same reasoning as JWT_SECRET above — lets tech-step-worker.routes.test.ts
|
# 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:
|
||||||
# Four independent jobs, no needs: between them — each starts in parallel
|
# Five independent jobs, no needs: between them — each starts in parallel
|
||||||
# and reports as its own check, instead of the previous single chained
|
# and reports as its own check, instead of the previous single chained
|
||||||
# "lint-and-test then e2e" pipeline.
|
# "lint-and-test then e2e" pipeline.
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: https://github.com/pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -49,34 +55,80 @@ jobs:
|
||||||
POSTGRES_PASSWORD: ci
|
POSTGRES_PASSWORD: ci
|
||||||
POSTGRES_DB: batchcooking_ci
|
POSTGRES_DB: batchcooking_ci
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5433:5432
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 5s
|
--health-interval 5s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: https://github.com/pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
|
- uses: https://github.com/astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
enable-cache: true
|
||||||
|
|
||||||
|
# `services:` (like the `postgres` container above) can only pull an
|
||||||
|
# already-published image — it can't build
|
||||||
|
# services/tech-step-intent-service/Dockerfile from this checkout.
|
||||||
|
# Running `uvicorn` as a plain background step instead: it keeps
|
||||||
|
# running for the rest of this job (GitHub Actions steps in one job
|
||||||
|
# share the same runner process tree), and `pnpm --filter api test`
|
||||||
|
# below needs a real instance to talk to per this repo's "never mock
|
||||||
|
# an internal service" test convention — same reasoning as the real
|
||||||
|
# `postgres` container just above, not a mock HTTP server.
|
||||||
|
- name: Install services/tech-step-intent-service
|
||||||
|
working-directory: services/tech-step-intent-service
|
||||||
|
run: uv sync --frozen
|
||||||
|
- name: Start services/tech-step-intent-service in the background
|
||||||
|
working-directory: services/tech-step-intent-service
|
||||||
|
run: |
|
||||||
|
uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 &
|
||||||
|
# `/health` only returns 200 once this service has finished
|
||||||
|
# training itself from scratch (no model ever persisted to disk —
|
||||||
|
# see its own README) — measured at ~540s (fr) / ~390s (en),
|
||||||
|
# ~930s combined, against the current ~74-technique corpus (see
|
||||||
|
# docker-compose.yml's healthcheck for the same reasoning and why
|
||||||
|
# this grew slightly from the original ~670s).
|
||||||
|
timeout 1200 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done'
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm --filter api exec prisma migrate deploy
|
- run: pnpm --filter api exec prisma migrate deploy
|
||||||
- run: pnpm --filter api test
|
- run: pnpm --filter api test
|
||||||
|
|
||||||
|
intent-service-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: https://github.com/astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
enable-cache: true
|
||||||
|
|
||||||
|
- name: Install services/tech-step-intent-service
|
||||||
|
working-directory: services/tech-step-intent-service
|
||||||
|
run: uv sync --frozen
|
||||||
|
- name: Run pytest
|
||||||
|
working-directory: services/tech-step-intent-service
|
||||||
|
run: uv run pytest -q
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: https://github.com/pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -87,17 +139,17 @@ jobs:
|
||||||
e2e:
|
e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: https://github.com/pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Cache Cypress binary
|
- name: Cache Cypress binary
|
||||||
uses: actions/cache@v4
|
uses: https://github.com/actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/Cypress
|
path: ~/.cache/Cypress
|
||||||
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
|
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -71,11 +71,12 @@ web_modules/
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.test.example
|
!.env.test.example
|
||||||
|
|
||||||
# node-nlp's default auto-save file (apps/api/src/lib/recipe-matching/
|
# Python virtualenvs/caches for services/tech-step-intent-service (this repo
|
||||||
# tech-step-matcher.ts explicitly disables autoSave/autoLoad, but this is a
|
# is otherwise all-Node — see that service's own .gitignore for the rest;
|
||||||
# belt-and-suspenders guard against it ever reappearing — a stale trained
|
# duplicated here too since some tooling only honors the repo-root file).
|
||||||
# model on disk must never silently shadow TECH_STEP_TRAINING_DATA).
|
services/tech-step-intent-service/.venv/
|
||||||
model.nlp
|
services/tech-step-intent-service/__pycache__/
|
||||||
|
services/tech-step-intent-service/.pytest_cache/
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
.cache
|
.cache
|
||||||
|
|
|
||||||
32
README.md
32
README.md
|
|
@ -44,6 +44,8 @@ runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans
|
||||||
- Node.js 22 (voir `.nvmrc`)
|
- Node.js 22 (voir `.nvmrc`)
|
||||||
- pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle)
|
- pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle)
|
||||||
- Docker (pour Postgres en local)
|
- Docker (pour Postgres en local)
|
||||||
|
- Python 3.12+ et [`uv`](https://docs.astral.sh/uv/) (pour
|
||||||
|
`services/tech-step-intent-service` en dev natif — requis, voir plus bas)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -100,6 +102,17 @@ pnpm --filter api exec prisma migrate dev
|
||||||
# techniques...) — automatique après `prisma migrate reset`, sinon à la main :
|
# techniques...) — automatique après `prisma migrate reset`, sinon à la main :
|
||||||
pnpm --filter api prisma:seed
|
pnpm --filter api prisma:seed
|
||||||
|
|
||||||
|
# Microservice de détection des techniques (spaCy) — requis, `pnpm dev:api`
|
||||||
|
# ne peut plus détecter aucune technique de cuisine sans lui. Lance-le en
|
||||||
|
# premier et laisse-le tourner : il s'entraîne lui-même à chaque démarrage
|
||||||
|
# (~11 minutes pour le corpus actuel, voir son propre README) avant de
|
||||||
|
# répondre quoi que ce soit sur /health.
|
||||||
|
cd services/tech-step-intent-service
|
||||||
|
uv sync
|
||||||
|
cp .env.example .env # édite-le : même INTENT_SERVICE_SECRET que apps/api/.env
|
||||||
|
uv run uvicorn intent_service.main:app --reload --port 8000
|
||||||
|
cd ../..
|
||||||
|
|
||||||
# Backend (http://localhost:3000)
|
# Backend (http://localhost:3000)
|
||||||
pnpm dev:api
|
pnpm dev:api
|
||||||
|
|
||||||
|
|
@ -137,7 +150,7 @@ pnpm --filter web cy:run:component # tests de composant UI isolés (Cypress com
|
||||||
pnpm build # build de tous les workspaces
|
pnpm build # build de tous les workspaces
|
||||||
```
|
```
|
||||||
|
|
||||||
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute quatre jobs indépendants (`lint`, `test`, `build`, `e2e` — ce dernier lance aussi `cy:run:component`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
|
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute cinq jobs indépendants (`lint`, `test`, `intent-service-test`, `build`, `e2e` — ce dernier lance aussi `cy:run:component`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. `test` démarre `services/tech-step-intent-service` en arrière-plan (voir ce fichier) puisque la suite Mocha ne mocke jamais un service interne. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
|
||||||
|
|
||||||
### Base de test isolée de la base de dev (`apps/api`)
|
### Base de test isolée de la base de dev (`apps/api`)
|
||||||
|
|
||||||
|
|
@ -158,6 +171,13 @@ Un garde-fou (`assertRunningAgainstTestDatabase()`) refuse d'exécuter
|
||||||
`resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la
|
`resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la
|
||||||
seule base qu'il doit rejeter est ta vraie base de dev.
|
seule base qu'il doit rejeter est ta vraie base de dev.
|
||||||
|
|
||||||
|
`services/tech-step-intent-service` doit aussi tourner en local avant
|
||||||
|
`pnpm --filter api test` — les tests touchant `tech-step-matcher.ts` passent
|
||||||
|
par le vrai service (jamais un mock, voir
|
||||||
|
[specs/dev-conventions.md](specs/dev-conventions.md)) et échouent avec une
|
||||||
|
erreur de connexion, pas une assertion utile, s'il n'est pas démarré. Voir la
|
||||||
|
section [Développement](#développement) ci-dessus.
|
||||||
|
|
||||||
## Déploiement
|
## Déploiement
|
||||||
|
|
||||||
Une seule image Docker (`apps/api/Dockerfile`) sert à la fois l'API et le frontend
|
Une seule image Docker (`apps/api/Dockerfile`) sert à la fois l'API et le frontend
|
||||||
|
|
@ -180,10 +200,12 @@ synchronisation de la table `sources` depuis le registre d'adaptateurs de code
|
||||||
puis `node dist/server.js`. Les trois étapes sont sûres/idempotentes à
|
puis `node dist/server.js`. Les trois étapes sont sûres/idempotentes à
|
||||||
répéter à chaque redémarrage du conteneur.
|
répéter à chaque redémarrage du conteneur.
|
||||||
|
|
||||||
`docker-compose.yml` ne définit donc que deux services : `postgres` et `app` (un
|
Le duo `postgres`/`app` de `docker-compose.yml` n'expose donc qu'un seul port
|
||||||
seul port, `APP_PORT`, défaut `3000` — plus de `WEB_PORT`/`CORS_ORIGIN` à
|
applicatif, `APP_PORT` (défaut `3000`) — plus de `WEB_PORT`/`CORS_ORIGIN` à
|
||||||
coordonner entre deux origines, le frontend et l'API sont désormais servis depuis
|
coordonner entre deux origines, le frontend et l'API sont désormais servis
|
||||||
la même origine).
|
depuis la même origine. Les deux autres services du fichier
|
||||||
|
(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
|
||||||
|
au host — voir leurs propres README pour leur rôle.
|
||||||
|
|
||||||
**Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
|
**Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
|
||||||
production est reliée directement au dépôt Git et reconstruit elle-même
|
production est reliée directement au dépôt Git et reconstruit elle-même
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,13 @@ 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,6 +14,15 @@ 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,5 +2,6 @@
|
||||||
"extension": ["ts"],
|
"extension": ["ts"],
|
||||||
"spec": "test/**/*.test.ts",
|
"spec": "test/**/*.test.ts",
|
||||||
"node-option": ["import=tsx"],
|
"node-option": ["import=tsx"],
|
||||||
"timeout": 10000
|
"timeout": 10000,
|
||||||
|
"require": ["test-support/mocha-root-hooks.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"node-nlp": "4.27.0",
|
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "utensil" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "utensil_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "utensil_key_key" ON "utensil"("key");
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "step_tech_step_ingredient" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"step_id" INTEGER NOT NULL,
|
||||||
|
"tech_step_order" INTEGER NOT NULL,
|
||||||
|
"ingredient_id" INTEGER NOT NULL,
|
||||||
|
"quantity" DECIMAL(10,2),
|
||||||
|
"unit_id" INTEGER,
|
||||||
|
"start" INTEGER NOT NULL,
|
||||||
|
"end" INTEGER NOT NULL,
|
||||||
|
"source" TEXT NOT NULL DEFAULT 'auto',
|
||||||
|
|
||||||
|
CONSTRAINT "step_tech_step_ingredient_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "step_tech_step_utensil" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"step_id" INTEGER NOT NULL,
|
||||||
|
"tech_step_order" INTEGER NOT NULL,
|
||||||
|
"utensil_id" INTEGER NOT NULL,
|
||||||
|
"start" INTEGER NOT NULL,
|
||||||
|
"end" INTEGER NOT NULL,
|
||||||
|
"source" TEXT NOT NULL DEFAULT 'auto',
|
||||||
|
|
||||||
|
CONSTRAINT "step_tech_step_utensil_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_step_id_tech_step_order_fkey" FOREIGN KEY ("step_id", "tech_step_order") REFERENCES "step_tech_step"("step_id", "order") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_unit_id_fkey" FOREIGN KEY ("unit_id") REFERENCES "unit"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "step_tech_step_utensil" ADD CONSTRAINT "step_tech_step_utensil_step_id_tech_step_order_fkey" FOREIGN KEY ("step_id", "tech_step_order") REFERENCES "step_tech_step"("step_id", "order") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "step_tech_step_utensil" ADD CONSTRAINT "step_tech_step_utensil_utensil_id_fkey" FOREIGN KEY ("utensil_id") REFERENCES "utensil"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -515,12 +515,15 @@ model Ingredient {
|
||||||
/// catalog's own search with this ingredient's name).
|
/// catalog's own search with this ingredient's name).
|
||||||
reproducible Boolean @default(false)
|
reproducible Boolean @default(false)
|
||||||
|
|
||||||
recipes RecipeIngredient[]
|
recipes RecipeIngredient[]
|
||||||
allergies IngredientAllergy[]
|
allergies IngredientAllergy[]
|
||||||
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
|
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
|
||||||
dislikedBy UserProfileDislikedIngredient[]
|
dislikedBy UserProfileDislikedIngredient[]
|
||||||
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
|
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
|
||||||
diets IngredientDiet[]
|
diets IngredientDiet[]
|
||||||
|
/// Mentions of this ingredient detected in a step's free text alongside a
|
||||||
|
/// technique — see `StepTechStepIngredient`.
|
||||||
|
stepTechSteps StepTechStepIngredient[]
|
||||||
|
|
||||||
@@map("ingredients")
|
@@map("ingredients")
|
||||||
}
|
}
|
||||||
|
|
@ -596,7 +599,13 @@ model Unit {
|
||||||
type UnitType
|
type UnitType
|
||||||
toBaseFactor Decimal @default(1) @map("to_base_factor") @db.Decimal(12, 4)
|
toBaseFactor Decimal @default(1) @map("to_base_factor") @db.Decimal(12, 4)
|
||||||
|
|
||||||
recipeIngredients RecipeIngredient[]
|
recipeIngredients RecipeIngredient[]
|
||||||
|
/// Ingredient mentions detected alongside a technique in a step's free
|
||||||
|
/// text (e.g. "50g" resolved against this `Unit`) — see
|
||||||
|
/// `StepTechStepIngredient`. Distinct from `recipeIngredients` above
|
||||||
|
/// (the recipe's structured ingredient list): a step can mention a
|
||||||
|
/// quantity+unit that was never itself an ingredient list line.
|
||||||
|
stepTechStepIngredients StepTechStepIngredient[]
|
||||||
|
|
||||||
@@map("unit")
|
@@map("unit")
|
||||||
}
|
}
|
||||||
|
|
@ -627,13 +636,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 node-nlp model trained from in-code data
|
/// a spaCy-based model (`services/tech-step-intent-service`) trained from
|
||||||
/// (`tech-step-training-data.ts`) once regexes turned out unable to
|
/// in-code data (`tech-step-training-data.ts`) once regexes turned out
|
||||||
/// generalize past their own literal vocabulary. Nothing queries/edits
|
/// unable to generalize past their own literal vocabulary. Nothing
|
||||||
/// that matching data at runtime anymore (it only ever feeds the
|
/// queries/edits that matching data at runtime anymore (it only ever feeds
|
||||||
/// classifier's one-time training pass), so it no longer needs a table of
|
/// that service's one-time training pass), so it no longer needs a table
|
||||||
/// its own — this row now only exists to be a stable id/key other tables
|
/// of its own — this row now only exists to be a stable id/key other
|
||||||
/// (`StepTechStep`) reference.
|
/// tables (`StepTechStep`) reference.
|
||||||
model TechStep {
|
model TechStep {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String @unique
|
key String @unique
|
||||||
|
|
@ -652,6 +661,25 @@ 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
|
||||||
|
|
@ -720,13 +748,72 @@ model StepTechStep {
|
||||||
contextEnd Int? @map("context_end")
|
contextEnd Int? @map("context_end")
|
||||||
source String @default("auto")
|
source String @default("auto")
|
||||||
|
|
||||||
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
|
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
|
||||||
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
|
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
|
||||||
|
/// Ingredients mentioned in the same clause as this technique occurrence
|
||||||
|
/// — see `StepTechStepIngredient`.
|
||||||
|
ingredients StepTechStepIngredient[]
|
||||||
|
/// Utensils mentioned in the same clause as this technique occurrence —
|
||||||
|
/// see `StepTechStepUtensil`.
|
||||||
|
utensils StepTechStepUtensil[]
|
||||||
|
|
||||||
@@id([stepId, order])
|
@@id([stepId, order])
|
||||||
@@map("step_tech_step")
|
@@map("step_tech_step")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An ingredient mention found in the same *clause* as one `StepTechStep`
|
||||||
|
/// occurrence (`tech-step-matcher.ts`'s `matchTechStepSpans` — clauses are
|
||||||
|
/// already the unit a technique is judged on, see that file's doc comment,
|
||||||
|
/// so "same clause" is the association rule, no dependency-parsing needed).
|
||||||
|
/// `quantity`/`unitId` are best-effort, populated only when a leading
|
||||||
|
/// numeric expression immediately preceding the ingredient mention resolved
|
||||||
|
/// against the `Unit` catalog (`ingredient-matcher.ts`'s
|
||||||
|
/// `findIngredientMentions`) — both `null` when the clause names the
|
||||||
|
/// ingredient with no quantity ("ajouter le sel"). `start`/`end` are the
|
||||||
|
/// ingredient mention's own span in `Step.description`, same `[start, end)`
|
||||||
|
/// convention as `StepTechStep.start`/`end`. `source` mirrors
|
||||||
|
/// `StepTechStep.source` (`"auto"` today, room for a future user
|
||||||
|
/// correction without a shape change).
|
||||||
|
model StepTechStepIngredient {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
stepId Int @map("step_id")
|
||||||
|
techStepOrder Int @map("tech_step_order")
|
||||||
|
ingredientId Int @map("ingredient_id")
|
||||||
|
quantity Decimal? @db.Decimal(10, 2)
|
||||||
|
unitId Int? @map("unit_id")
|
||||||
|
start Int
|
||||||
|
end Int
|
||||||
|
source String @default("auto")
|
||||||
|
|
||||||
|
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
|
||||||
|
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||||
|
unit Unit? @relation(fields: [unitId], references: [id])
|
||||||
|
|
||||||
|
@@map("step_tech_step_ingredient")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A utensil mention found in the same clause as one `StepTechStep`
|
||||||
|
/// occurrence — same association rule as `StepTechStepIngredient` (see its
|
||||||
|
/// doc comment). Detected by
|
||||||
|
/// `services/tech-step-intent-service`'s own utensil `PhraseMatcher`
|
||||||
|
/// (`intent_service/utensil_vocabulary.py`), returned alongside technique
|
||||||
|
/// entities in `POST /v1/process` and filtered to this clause's span by
|
||||||
|
/// `tech-step-matcher.ts`.
|
||||||
|
model StepTechStepUtensil {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
stepId Int @map("step_id")
|
||||||
|
techStepOrder Int @map("tech_step_order")
|
||||||
|
utensilId Int @map("utensil_id")
|
||||||
|
start Int
|
||||||
|
end Int
|
||||||
|
source String @default("auto")
|
||||||
|
|
||||||
|
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
|
||||||
|
utensil Utensil @relation(fields: [utensilId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("step_tech_step_utensil")
|
||||||
|
}
|
||||||
|
|
||||||
/// One user-submitted correction to a `Step`'s detected techniques —
|
/// 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,6 +13,7 @@ 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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -50,6 +51,7 @@ 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,6 +71,25 @@ 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,11 +62,12 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }>
|
||||||
//
|
//
|
||||||
// Just a flat list of stable ids here — the actual matching data (per-
|
// Just a flat list of stable ids here — the actual matching data (per-
|
||||||
// locale synonym lists + example phrasings the classifier trains on) lives
|
// locale synonym lists + example phrasings the classifier trains on) lives
|
||||||
// in `lib/recipe-matching/tech-step-training-data.ts`'s
|
// in `services/tech-step-intent-service/intent_service/training_data.py`'s
|
||||||
// `TECH_STEP_TRAINING_DATA`, not here: unlike this list, it's read by
|
// `TECH_STEP_TRAINING_DATA`, not here: it's owned and trained entirely by
|
||||||
// `TechStepClassifierService`'s training pass, not the seed script, so it
|
// that separate Python service (see its own README), not read by this
|
||||||
// doesn't belong alongside the rest of this file's DB-seeded reference
|
// seed script at all, so it doesn't belong alongside the rest of this
|
||||||
// data. Every entry here must have a matching entry there.
|
// file's DB-seeded reference data. Every entry here must have a matching
|
||||||
|
// entry there.
|
||||||
export const TECH_STEPS: string[] = [
|
export const TECH_STEPS: string[] = [
|
||||||
"cook",
|
"cook",
|
||||||
"fry",
|
"fry",
|
||||||
|
|
@ -94,6 +95,100 @@ export const TECH_STEPS: string[] = [
|
||||||
"bake",
|
"bake",
|
||||||
"plate",
|
"plate",
|
||||||
"coat",
|
"coat",
|
||||||
|
// Lexique de techniques ajouté par la suite — voir
|
||||||
|
// `services/tech-step-intent-service/intent_service/training_data.py`
|
||||||
|
// pour les synonymes/phrases d'exemple de chacune.
|
||||||
|
"baste",
|
||||||
|
"appertize",
|
||||||
|
"whiskPale",
|
||||||
|
"goldenBrown",
|
||||||
|
"braise",
|
||||||
|
"truss",
|
||||||
|
"caramelize",
|
||||||
|
"score",
|
||||||
|
"lineMold",
|
||||||
|
"clarify",
|
||||||
|
"compote",
|
||||||
|
"concasse",
|
||||||
|
"confit",
|
||||||
|
"julienne",
|
||||||
|
"brunoise",
|
||||||
|
"mirepoix",
|
||||||
|
"paysanne",
|
||||||
|
"blindBake",
|
||||||
|
"bainMarie",
|
||||||
|
"smother",
|
||||||
|
"decant",
|
||||||
|
"dilute",
|
||||||
|
"punchDown",
|
||||||
|
"disgorge",
|
||||||
|
"loosen",
|
||||||
|
"shellEgg",
|
||||||
|
"scald",
|
||||||
|
"pod",
|
||||||
|
"emulsify",
|
||||||
|
"hollowOut",
|
||||||
|
"shock",
|
||||||
|
"setGel",
|
||||||
|
"glaze",
|
||||||
|
"thicken",
|
||||||
|
"filet",
|
||||||
|
"proof",
|
||||||
|
"peelBlanch",
|
||||||
|
"whipUp",
|
||||||
|
"moisten",
|
||||||
|
"pasteurize",
|
||||||
|
"poach",
|
||||||
|
"reduce",
|
||||||
|
"rubIn",
|
||||||
|
"dustWithFlour",
|
||||||
|
"sweat",
|
||||||
|
"sift",
|
||||||
|
"toast",
|
||||||
|
"zest",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Same authoring convention as `TECH_STEPS` right above (stable English
|
||||||
|
// camelCase uid, French label in `apps/web`'s `locales/fr/translation.json`
|
||||||
|
// under `catalog.utensils.<key>`) — but unlike `TECH_STEPS`, the matching
|
||||||
|
// data (per-locale synonym lists a `PhraseMatcher` matches against) lives
|
||||||
|
// in `services/tech-step-intent-service/intent_service/utensil_vocabulary.py`'s
|
||||||
|
// `UTENSIL_VOCABULARY`, not `training_data.py`: no textcat/training
|
||||||
|
// involved, a utensil mention doesn't need to be classified, only matched.
|
||||||
|
// Every entry here must have a matching entry there. See
|
||||||
|
// `StepTechStepUtensil` in schema.prisma for how a mention gets attached to
|
||||||
|
// a detected technique.
|
||||||
|
export const UTENSILS: string[] = [
|
||||||
|
"pan",
|
||||||
|
"saucepan",
|
||||||
|
"pot",
|
||||||
|
"knife",
|
||||||
|
"whisk",
|
||||||
|
"bowl",
|
||||||
|
"bakingSheet",
|
||||||
|
"mold",
|
||||||
|
"colander",
|
||||||
|
"cuttingBoard",
|
||||||
|
"oven",
|
||||||
|
"blender",
|
||||||
|
"mixer",
|
||||||
|
"spatula",
|
||||||
|
"ladle",
|
||||||
|
"grater",
|
||||||
|
"rollingPin",
|
||||||
|
"lid",
|
||||||
|
"tongs",
|
||||||
|
"peeler",
|
||||||
|
"sieve",
|
||||||
|
"foodProcessor",
|
||||||
|
"steamerBasket",
|
||||||
|
"skewer",
|
||||||
|
"pastryBrush",
|
||||||
|
"ramekin",
|
||||||
|
"dish",
|
||||||
|
"wok",
|
||||||
|
"thermometer",
|
||||||
|
"mandoline",
|
||||||
];
|
];
|
||||||
|
|
||||||
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
|
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
|
||||||
|
|
@ -1199,6 +1294,12 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
||||||
await prisma.techStep.upsert({ where: { key }, update: {}, create: { key } });
|
await prisma.techStep.upsert({ where: { key }, update: {}, create: { key } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Utensil: same idempotent bare id/key upsert as TechStep right above —
|
||||||
|
// no matching data alongside it either (see `UTENSILS`' own comment).
|
||||||
|
for (const key of UTENSILS) {
|
||||||
|
await prisma.utensil.upsert({ where: { key }, update: {}, create: { key } });
|
||||||
|
}
|
||||||
|
|
||||||
// `Allergy` itself carries no `key` — it's the selectable instance of a
|
// `Allergy` itself carries no `key` — it's the selectable instance of a
|
||||||
// keyed `Category` (see schema.prisma) — so seeding an allergen means one
|
// keyed `Category` (see schema.prisma) — so seeding an allergen means one
|
||||||
// Category (upserted by key) plus exactly one Allergy row under it,
|
// Category (upserted by key) plus exactly one Allergy row under it,
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,171 @@ 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
|
||||||
|
|
|
||||||
102
apps/api/src/lib/recipe-matching/intent-service-client.ts
Normal file
102
apps/api/src/lib/recipe-matching/intent-service-client.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { env } from "../../config/env.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin fetch wrapper around `services/tech-step-intent-service`'s HTTP
|
||||||
|
* contract (`POST /v1/process`) — the microservice
|
||||||
|
* {@link TechStepClassifierService} (`tech-step-matcher.ts`) delegates NER +
|
||||||
|
* intent classification to, in place of the `node-nlp` `NlpManager` it used
|
||||||
|
* to own directly. See that service's own README for the full contract and
|
||||||
|
* why it never touches Postgres itself — it also owns its own training
|
||||||
|
* corpus now (`training_data.py`), trained once at its own startup, so
|
||||||
|
* `apps/api` never pushes anything to it; `process()` below is this
|
||||||
|
* client's only method.
|
||||||
|
*
|
||||||
|
* Authenticated with `INTENT_SERVICE_SECRET` — the inverse direction of
|
||||||
|
* `requireInternalWorker`'s `INTERNAL_WORKER_SECRET` (this time `apps/api`
|
||||||
|
* is the caller, not the callee), but the same "one flat shared secret"
|
||||||
|
* shape.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One candidate mention one of the service's two `PhraseMatcher`s found — offsets `[start, end)`, same convention as `String.prototype.slice`. Mirrors `EntityPayload` (Python `schemas.py`). `kind` distinguishes a technique mention (`self._matcher`, the corpus-trained one) from a utensil mention (`self._utensil_matcher`, static — see `utensil_vocabulary.py`) — `tech-step-matcher.ts` resolves each against a different catalog (`TechStep`/`Utensil`). */
|
||||||
|
export interface IntentServiceEntity {
|
||||||
|
uid: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
kind: "technique" | "utensil";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full result of a `POST /v1/process` call — mirrors `ProcessResponse` (Python `schemas.py`). `intent` is `null` only when `locale` isn't one this service trains for, or `text` is blank; otherwise always a real `uid` (the Python service's `textcat` has no "None" sentinel, unlike node-nlp — see that service's README). */
|
||||||
|
export interface IntentServiceProcessResult {
|
||||||
|
entities: IntentServiceEntity[];
|
||||||
|
intent: string | null;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client for `services/tech-step-intent-service` — a real class (not a
|
||||||
|
* plain object of functions) per this repo's service-style-logic
|
||||||
|
* convention, even though it holds no state of its own: it's used as the
|
||||||
|
* one shared {@link intentServiceClient} singleton below, same reasoning as
|
||||||
|
* `TechStepClassifierService` itself.
|
||||||
|
*/
|
||||||
|
export class IntentServiceClient {
|
||||||
|
/**
|
||||||
|
* Performs a JSON request against the intent service and returns the
|
||||||
|
* parsed body.
|
||||||
|
*
|
||||||
|
* @throws {Error} if the response status is not in the 2xx range, or the
|
||||||
|
* request itself fails (network error, service down) — left as a plain
|
||||||
|
* `Error` rather than a typed `HttpError`: this is an internal
|
||||||
|
* service-to-service call, not a request `apps/api`'s own HTTP layer
|
||||||
|
* needs to map to a client-facing status code (see
|
||||||
|
* `TechStepClassifierService.warmUp`'s retry in `server.ts` for how a
|
||||||
|
* failure here is actually handled).
|
||||||
|
*/
|
||||||
|
private async _request<TResponseBody>(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
): Promise<TResponseBody> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${env.INTENT_SERVICE_BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Intent-Service-Secret": env.INTENT_SERVICE_SECRET,
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => "");
|
||||||
|
throw new Error(`${init.method ?? "GET"} ${path} failed: ${response.status} ${body}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as TResponseBody;
|
||||||
|
} catch (err) {
|
||||||
|
// Rethrown as-is — every caller (`TechStepClassifierService`) already
|
||||||
|
// wraps its own `await`s per the repo's try/catch convention; this is
|
||||||
|
// just where the `await` itself has to sit inside one.
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equivalent to the old `NlpManager.process(locale, text)` — returns every
|
||||||
|
* candidate technique mention (NER) plus the intent classifier's verdict
|
||||||
|
* for `text` as a whole, whether `text` is a full step description or a
|
||||||
|
* single clause `TechStepClassifierService` already cut out of one (this
|
||||||
|
* service doesn't know or care which, exactly like `NlpManager` before
|
||||||
|
* it).
|
||||||
|
*/
|
||||||
|
public async process(locale: string, text: string): Promise<IntentServiceProcessResult> {
|
||||||
|
try {
|
||||||
|
return await this._request("/v1/process", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ locale, text }),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single shared instance — stateless, no reason for more than one (same reasoning as `techStepClassifier`/`prisma`). */
|
||||||
|
export const intentServiceClient = new IntentServiceClient();
|
||||||
|
|
@ -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 `tech-step-training-data.ts` must clear (see that
|
* future change to `services/tech-step-intent-service`'s `training_data.py`
|
||||||
* module's own doc comment).
|
* must clear (see that 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 tech-step-training-data.ts).
|
// entry's own comment in training_data.py).
|
||||||
{
|
{
|
||||||
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,8 +3,9 @@
|
||||||
* 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
|
||||||
* `tech-step-training-data.ts` (including the LLM-assisted suggestions the
|
* `services/tech-step-intent-service`'s `training_data.py` (including the
|
||||||
* worker in `services/tech-step-llm-worker` proposes) is expected to run
|
* LLM-assisted suggestions the worker in `services/tech-step-llm-worker`
|
||||||
|
* proposes) is expected to run
|
||||||
* through `tech-step-eval.test.ts`'s regression gate, which calls
|
* 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,6 +1,11 @@
|
||||||
import { NlpManager } from "node-nlp";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js";
|
import {
|
||||||
|
findIngredientMentions,
|
||||||
|
type IngredientMention,
|
||||||
|
loadIngredientCatalog,
|
||||||
|
loadUnitCatalog,
|
||||||
|
} from "./ingredient-matcher.js";
|
||||||
|
import { intentServiceClient } from "./intent-service-client.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe
|
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe
|
||||||
|
|
@ -15,25 +20,27 @@ import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js";
|
||||||
* generalize past its own vocabulary — a step describing melting butter as
|
* generalize past its own vocabulary — a step describing melting butter as
|
||||||
* "jusqu'à ce que le beurre ait disparu dans la poêle" mentions no verb any
|
* "jusqu'à ce que le beurre ait disparu dans la poêle" mentions no verb any
|
||||||
* regex could anchor on, yet unmistakably *means* `melt`. Replaced with a
|
* regex could anchor on, yet unmistakably *means* `melt`. Replaced with a
|
||||||
* small hybrid pipeline built on `node-nlp` ({@link TechStepClassifierService}):
|
* small hybrid pipeline (originally built on `node-nlp`, now entirely
|
||||||
|
* delegated to `services/tech-step-intent-service` — a spaCy-based
|
||||||
|
* microservice, see {@link IntentServiceClient} and that service's own
|
||||||
|
* README):
|
||||||
*
|
*
|
||||||
* 1. **NER** (node-nlp enum entities, `synonyms` in `TECH_STEP_TRAINING_DATA`)
|
* 1. **NER** (the intent service's `PhraseMatcher`, built from its own
|
||||||
* finds every *candidate* technique mention in the whole
|
* `training_data.py`'s `synonyms`) finds every *candidate* technique
|
||||||
* description, each with its exact character span — mechanically the
|
* mention in the whole description, each with its exact character span —
|
||||||
* same job the old regexes did, just as flat synonym lists instead of
|
* mechanically the same job the old regexes did, just as flat synonym
|
||||||
* hand-written patterns (node-nlp's own stemmer/fuzzy matching already
|
* lists instead of hand-written patterns. This step alone is *not* the
|
||||||
* covers minor conjugation/typo variance the regexes had to enumerate
|
* final answer — see step 3.
|
||||||
* by hand). This step alone is *not* the final answer — see step 3.
|
|
||||||
* 2. The description is cut into clauses around those candidate spans
|
* 2. The description is cut into clauses around those candidate spans
|
||||||
* ({@link splitIntoClauses}) — a step naming two techniques ("Dans une
|
* ({@link splitIntoClauses}) — a step naming two techniques ("Dans une
|
||||||
* poêle chaude, faire chauffer une noix de beurre" is both `preheat`
|
* poêle chaude, faire chauffer une noix de beurre" is both `preheat`
|
||||||
* and `melt`) needs each judged on its own surrounding context, not the
|
* and `melt`) needs each judged on its own surrounding context, not the
|
||||||
* whole step lumped into one classification.
|
* whole step lumped into one classification.
|
||||||
* 3. **NLP intent classification** (node-nlp's `NlpManager`, trained on
|
* 3. **NLP intent classification** (the intent service's `textcat`, trained
|
||||||
* `TECH_STEP_TRAINING_DATA`'s `utterances`) then classifies each clause
|
* on its own `training_data.py`'s `utterances`) then classifies each
|
||||||
* on its own — this is what actually delivers "meaning, not keywords":
|
* clause on its own — this is what actually delivers "meaning, not
|
||||||
* the classifier was deliberately trained on paraphrases that never use
|
* keywords": the classifier was deliberately trained on paraphrases that
|
||||||
* the technique's own verb (e.g. "jusqu'à ce que le beurre ait
|
* never use the technique's own verb (e.g. "jusqu'à ce que le beurre ait
|
||||||
* disparu" for `melt`), so a clause reaching it gets labeled by what it
|
* disparu" for `melt`), so a clause reaching it gets labeled by what it
|
||||||
* was trained to recognize as *meaning* a technique, not by which
|
* was trained to recognize as *meaning* a technique, not by which
|
||||||
* literal word the NER step happened to anchor on. The NER-implied
|
* literal word the NER step happened to anchor on. The NER-implied
|
||||||
|
|
@ -49,12 +56,12 @@ import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js";
|
||||||
*
|
*
|
||||||
* `normalizeText` and {@link splitIntoClauses} are pure (no DB/model
|
* `normalizeText` and {@link splitIntoClauses} are pure (no DB/model
|
||||||
* access) so they stay unit-testable in isolation (see
|
* access) so they stay unit-testable in isolation (see
|
||||||
* `test/tech-step-matcher.test.ts`); the classifier itself needs a one-time
|
* `test/tech-step-matcher.test.ts`); this class only ever needs a
|
||||||
* training pass (`_ensureTrained`, node-nlp's `NlpManager.train()`) plus a
|
* `TechStep.key -> id` lookup from the DB, memoized on the shared
|
||||||
* `TechStep.key -> id` lookup from the DB, both memoized on the shared
|
* {@link techStepClassifier} singleton rather than repeated per call — the
|
||||||
* {@link techStepClassifier} singleton rather than repeated per call —
|
* NLP model itself trains once, inside `services/tech-step-intent-service`'s
|
||||||
* training is the expensive part (a few hundred ms for this corpus), never
|
* own startup, entirely independently of this class (see that service's
|
||||||
* worth redoing per request let alone per step.
|
* README — this repo no longer pushes any corpus to it over HTTP).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -89,6 +96,15 @@ export function normalizeText(text: string): string {
|
||||||
* Persisted as `StepTechStep.start`/`end`/`contextStart`/`contextEnd`
|
* Persisted as `StepTechStep.start`/`end`/`contextStart`/`contextEnd`
|
||||||
* (`recipe.service.ts`) so the recipe detail view can highlight both spans,
|
* (`recipe.service.ts`) so the recipe detail view can highlight both spans,
|
||||||
* not just know a technique was mentioned somewhere.
|
* not just know a technique was mentioned somewhere.
|
||||||
|
*
|
||||||
|
* `ingredients`/`utensils` are the metadata found in this match's own
|
||||||
|
* *clause* (see this file's doc comment, point 2) — an ingredient/utensil
|
||||||
|
* mentioned in a different clause of the same description belongs to
|
||||||
|
* *that* clause's own match, never this one, the same "judged on its own
|
||||||
|
* surrounding context" rule the technique itself is judged by. Always `[]`
|
||||||
|
* rather than omitted when nothing was found, so every caller can iterate
|
||||||
|
* unconditionally. Persisted as `StepTechStepIngredient`/`StepTechStepUtensil`
|
||||||
|
* rows (`recipe.service.ts`).
|
||||||
*/
|
*/
|
||||||
export interface TechStepMatch {
|
export interface TechStepMatch {
|
||||||
techStepId: number;
|
techStepId: number;
|
||||||
|
|
@ -96,6 +112,21 @@ export interface TechStepMatch {
|
||||||
end: number;
|
end: number;
|
||||||
contextStart: number;
|
contextStart: number;
|
||||||
contextEnd: number;
|
contextEnd: number;
|
||||||
|
ingredients: IngredientMention[];
|
||||||
|
utensils: UtensilMention[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utensil mention found by the intent service's utensil `PhraseMatcher`
|
||||||
|
* (`kind: "utensil"` entities in `IntentServiceProcessResult`, see
|
||||||
|
* `intent-service-client.ts`), resolved to a local `Utensil.id` and
|
||||||
|
* attributed to whichever clause its span falls inside — same
|
||||||
|
* `[start, end)` convention as every other span in this file.
|
||||||
|
*/
|
||||||
|
export interface UtensilMention {
|
||||||
|
utensilId: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A candidate technique mention found by NER — the raw material {@link splitIntoClauses} cuts a description around. */
|
/** A candidate technique mention found by NER — the raw material {@link splitIntoClauses} cuts a description around. */
|
||||||
|
|
@ -241,18 +272,31 @@ export function splitIntoClauses(
|
||||||
* `TECH_STEP_TRAINING_DATA` — see `test/tech-step-matcher.test.ts` for the
|
* `TECH_STEP_TRAINING_DATA` — see `test/tech-step-matcher.test.ts` for the
|
||||||
* cases this threshold was picked to pass.
|
* cases this threshold was picked to pass.
|
||||||
*
|
*
|
||||||
* Raised from `0.65` after finding real (non-adversarial) misclassified
|
* Recalibrated for the migration off `node-nlp` to
|
||||||
* clauses that scored just above the old threshold — e.g. English recipe
|
* `services/tech-step-intent-service` (spaCy `textcat`, exclusive classes)
|
||||||
* text run through the French classifier (which must find *nothing*,
|
* — its score distribution is meaningfully different from node-nlp's own
|
||||||
* confirmed by `recipe-translation.test.ts`'s own locale-isolation test)
|
* classifier, and shifts again every time the corpus' technique count
|
||||||
* scored `0.69` for `boil`, essentially classifier noise on
|
* changes (more exclusive classes generally means a *lower* natural
|
||||||
* out-of-vocabulary input rather than a real, confident verdict. The
|
* confidence ceiling, softmax mass spread thinner).
|
||||||
* clauses this threshold exists to actually trust score far higher in
|
*
|
||||||
* practice (`0.91`–`1.0` for the real corrected cases found this session)
|
* Currently `0.25`, set against the corpus as expanded to ~74 techniques
|
||||||
* — `0.75` sits comfortably above the noise floor and below every genuine
|
* (`services/tech-step-intent-service/intent_service/training_data.py`,
|
||||||
* match seen so far.
|
* `_TRAINING_ITERATIONS = 25`, `textcat` trained on each technique's own
|
||||||
|
* `synonyms` in addition to its `utterances` — see that constant's own
|
||||||
|
* comment for the calibration history) from manual spot-checks, not yet a
|
||||||
|
* real `calibrate-tech-step-threshold.ts` sweep against
|
||||||
|
* `TECH_STEP_EVAL_DATASET` (needs Postgres — see that script's own doc
|
||||||
|
* comment): observed real-case scores ranged `0.31`-`0.89` (`simmer`
|
||||||
|
* lowest, still correct in argmax and anchored anyway; `melt` highest, the
|
||||||
|
* motivating anchor-less case), against a noise floor around `0.02`
|
||||||
|
* (English text through the French classifier). `0.25` sits with real
|
||||||
|
* margin above the noise floor and below every real case seen so far, but
|
||||||
|
* **this is a placeholder pending the real eval-dataset sweep** — do not
|
||||||
|
* treat it as load-bearing precision the way the original `0.45`
|
||||||
|
* (calibrated against the ~26-technique corpus, `TECH_STEP_EVAL_DATASET`
|
||||||
|
* F1 plateauing exactly there) was.
|
||||||
*/
|
*/
|
||||||
export const CONFIDENCE_THRESHOLD = 0.75;
|
export const CONFIDENCE_THRESHOLD = 0.25;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One clause's full classification detail — the finer-grained sibling of
|
* One clause's full classification detail — the finer-grained sibling of
|
||||||
|
|
@ -273,70 +317,45 @@ 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 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. */
|
/** The intent classifier's own top guess for this clause, whatever its score — `null` only when the intent service had nothing trained for `locale`, or the clause text was blank. Unlike {@link TechStepMatch}, never silently replaced by the anchor's uid — the whole point of this type is to expose the classifier's raw opinion, confident or not. */
|
||||||
intentUid: string | null;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trains and owns the `node-nlp` model behind {@link matchTechStepSpans} —
|
* Owns the `TechStep.key -> id` lookup behind {@link matchTechStepSpans} —
|
||||||
* a real class (not a plain object of functions) per this repo's
|
* a real class (not a plain object of functions) per this repo's
|
||||||
* service-style-logic convention, even though it's only ever used as the
|
* service-style-logic convention, even though it's only ever used as the
|
||||||
* one shared {@link techStepClassifier} singleton below: it holds real
|
* one shared {@link techStepClassifier} singleton below: it holds real
|
||||||
* state (the trained model, the memoized training/lookup promises), not
|
* state (the memoized lookup promise), not just grouped stateless helpers.
|
||||||
* just grouped stateless helpers.
|
* The actual NER/intent-classification model lives entirely in
|
||||||
|
* `services/tech-step-intent-service` (a separate process, trained from
|
||||||
|
* its own `training_data.py` at its own startup) — this class never
|
||||||
|
* trains or pushes anything to it, it only calls `POST /v1/process` and
|
||||||
|
* resolves whatever `uid` comes back to a local DB id.
|
||||||
*/
|
*/
|
||||||
export class TechStepClassifierService {
|
export class TechStepClassifierService {
|
||||||
/** node-nlp's manager — both NER (enum entities) and NLP (intent classification) live on the same instance, trained together. */
|
/** Memoized `TechStep.key -> id` lookup — resolved from the DB once, reused by every call rather than queried per request. `undefined` until the first call starts loading it, after which every caller (concurrent or not) awaits the same promise. */
|
||||||
private readonly _manager: NlpManager;
|
private _techStepIdsLoaded: Promise<void> | undefined;
|
||||||
/** Memoized training pass — `undefined` until the first call starts it, after which every caller (concurrent or not) awaits the same promise rather than retraining. */
|
|
||||||
private _trained: Promise<void> | undefined;
|
|
||||||
/** Memoized `TechStep.key -> id` lookup — training data only knows techniques by their stable `uid`/`key`, resolved to the real DB id once, alongside training. */
|
|
||||||
private _techStepIdByUid: Map<string, number> | undefined;
|
private _techStepIdByUid: Map<string, number> | undefined;
|
||||||
|
|
||||||
public constructor() {
|
/** Same memoized-lookup shape as {@link _techStepIdsLoaded}/{@link _techStepIdByUid}, for `Utensil.key -> id` instead — a `kind: "utensil"` entity from the intent service resolves through this map, never `_techStepIdByUid`. */
|
||||||
this._manager = new NlpManager({
|
private _utensilIdsLoaded: Promise<void> | undefined;
|
||||||
languages: ["fr", "en"],
|
private _utensilIdByUid: Map<string, number> | undefined;
|
||||||
forceNER: true,
|
|
||||||
nlu: { log: false },
|
|
||||||
// node-nlp's enum-entity NER defaults to a fuzzy (Levenshtein-based)
|
|
||||||
// 0.8 accuracy threshold — loose enough that e.g. "faire" (the
|
|
||||||
// generic French helper verb in almost every recipe step) fuzzy-
|
|
||||||
// matches `fry`'s synonym "frire" at 0.80, a false positive found
|
|
||||||
// while tuning this against the real training corpus. `1` (exact,
|
|
||||||
// after node-nlp's own case/accent/stemming normalization — real
|
|
||||||
// conjugation variance is still covered by listing each form in
|
|
||||||
// `tech-step-training-data.ts`) removed it without losing any real
|
|
||||||
// match. Precision matters more than recall for this stage — NER
|
|
||||||
// only proposes candidate split points, `_classifyClause`'s trained
|
|
||||||
// model (not fuzzy string distance) is what actually has to be
|
|
||||||
// right.
|
|
||||||
ner: { threshold: 1 },
|
|
||||||
// node-nlp defaults to `autoSave`/`autoLoad: true` — silently
|
|
||||||
// persisting the trained model to a `model.nlp` file in the process's
|
|
||||||
// cwd, and *loading from that file instead of retraining* the next
|
|
||||||
// time a manager is constructed, if the file already exists. Found
|
|
||||||
// this the hard way: a stray `model.nlp` appeared at the repo root
|
|
||||||
// after running this locally. That's the opposite of what this
|
|
||||||
// service wants — `TECH_STEP_TRAINING_DATA` in code is the single
|
|
||||||
// source of truth this always trains fresh from (see this file's own
|
|
||||||
// doc comment) — a stale on-disk model silently shadowing a
|
|
||||||
// corpus/threshold update would be a nasty, hard-to-notice class of
|
|
||||||
// bug. Both off; nothing here should ever touch disk.
|
|
||||||
autoSave: false,
|
|
||||||
autoLoad: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forces training plus node-nlp's own one-time lazy setup (loading its
|
* Forces the `TechStep.key -> id` lookup to load now, synchronously with
|
||||||
* bundled per-language stemmers/tokenizers on the *first* real
|
* server startup (see `server.ts`, which also retries this against a
|
||||||
* `NlpManager.process()` call takes a few seconds by itself, separate
|
* not-yet-reachable intent service), rather than stalling whichever
|
||||||
* from and much slower than the ~40ms `train()` pass — measured against
|
* request happens to be first to save/preview a recipe. Doesn't wait on
|
||||||
* this corpus while tuning the pipeline) to happen now, synchronously
|
* `services/tech-step-intent-service` finishing its own training — that
|
||||||
* with server startup (see `server.ts`), rather than stalling whichever
|
* service is only ever considered "up" by Docker Compose/CI once it
|
||||||
* request happens to be first to save/preview a recipe.
|
* already is (see that service's `GET /health`), so by the time this
|
||||||
|
* runs in a real deployment it's already trained; a request racing an
|
||||||
|
* intent service that's genuinely still starting just gets an empty
|
||||||
|
* match list back (see `IntentServiceProcessResult`'s own doc comment),
|
||||||
|
* not an error.
|
||||||
*/
|
*/
|
||||||
public async warmUp(): Promise<void> {
|
public async warmUp(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -360,26 +379,35 @@ export class TechStepClassifierService {
|
||||||
*/
|
*/
|
||||||
public async matchTechStepSpans(description: string, locale: string): Promise<TechStepMatch[]> {
|
public async matchTechStepSpans(description: string, locale: string): Promise<TechStepMatch[]> {
|
||||||
try {
|
try {
|
||||||
await this._ensureTrained();
|
await Promise.all([this._ensureTechStepIdsLoaded(), this._ensureUtensilIdsLoaded()]);
|
||||||
if (description.trim().length === 0) return [];
|
if (description.trim().length === 0) return [];
|
||||||
|
|
||||||
const nerResult = await this._manager.process(locale, description);
|
// Loaded fresh per call (once per step, see `recipe.service.ts`'s
|
||||||
|
// `matchStepsTechSteps`) rather than memoized like the id lookups
|
||||||
|
// above — same "cheap enough, and reference data can change between
|
||||||
|
// calls without a restart" posture `loadIngredientCatalog`/
|
||||||
|
// `loadUnitCatalog`'s own doc comments already describe for their
|
||||||
|
// other callers (`ingredient-matcher.ts`, `sources.service.ts`).
|
||||||
|
const [ingredientCatalog, unitCatalog] = await Promise.all([
|
||||||
|
loadIngredientCatalog(locale),
|
||||||
|
loadUnitCatalog(locale),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The intent service returns two kinds of candidate (see `kind` on
|
||||||
|
// `IntentServiceEntity`): technique mentions (its corpus-trained
|
||||||
|
// `PhraseMatcher`) and utensil mentions (its static one, see
|
||||||
|
// `utensil_vocabulary.py`). Only the former ever anchor a clause —
|
||||||
|
// `splitIntoClauses` cuts a description around *techniques*, a
|
||||||
|
// mentioned utensil doesn't introduce a clause boundary of its own,
|
||||||
|
// it just gets attributed to whichever clause its span falls inside
|
||||||
|
// (see the loop below). Its `start`/`end` are already `[start, end)`
|
||||||
|
// (matching `String.prototype.slice`), unlike node-nlp's inclusive
|
||||||
|
// `end` — no `+ 1` needed either.
|
||||||
|
const nerResult = await intentServiceClient.process(locale, description);
|
||||||
const candidates: TechniqueCandidate[] = nerResult.entities
|
const candidates: TechniqueCandidate[] = nerResult.entities
|
||||||
// node-nlp's language plugins also auto-extract their own built-in
|
.filter((entity) => entity.kind === "technique")
|
||||||
// entities (numbers, durations, dates…) alongside the enum
|
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
|
||||||
// entities `_train` registered from `TECH_STEP_TRAINING_DATA` —
|
const utensilEntities = nerResult.entities.filter((entity) => entity.kind === "utensil");
|
||||||
// `type === "enum"` is what tells the two apart; without this
|
|
||||||
// filter a step like "10 minutes" would hand `splitIntoClauses` a
|
|
||||||
// bogus "duration" candidate that resolves to no real technique.
|
|
||||||
.filter((entity) => entity.type === "enum")
|
|
||||||
.map((entity) => ({
|
|
||||||
uid: entity.entity,
|
|
||||||
start: entity.start,
|
|
||||||
// node-nlp's own `end` is inclusive (verified against a real
|
|
||||||
// trained model) — `+ 1` converts to this module's `[start, end)`
|
|
||||||
// convention, matching `String.prototype.slice`.
|
|
||||||
end: entity.end + 1,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const clauses = splitIntoClauses(description, candidates);
|
const clauses = splitIntoClauses(description, candidates);
|
||||||
const matches: TechStepMatch[] = [];
|
const matches: TechStepMatch[] = [];
|
||||||
|
|
@ -393,12 +421,35 @@ export class TechStepClassifierService {
|
||||||
// persist a dangling id.
|
// persist a dangling id.
|
||||||
if (techStepId === undefined) continue;
|
if (techStepId === undefined) continue;
|
||||||
const span = clause.anchor ?? { start: clause.start, end: clause.end };
|
const span = clause.anchor ?? { start: clause.start, end: clause.end };
|
||||||
|
|
||||||
|
const ingredients = findIngredientMentions(
|
||||||
|
description.slice(clause.start, clause.end),
|
||||||
|
ingredientCatalog,
|
||||||
|
unitCatalog,
|
||||||
|
locale,
|
||||||
|
).map((mention) => ({
|
||||||
|
...mention,
|
||||||
|
start: mention.start + clause.start,
|
||||||
|
end: mention.end + clause.start,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const utensils: UtensilMention[] = utensilEntities.flatMap((entity) => {
|
||||||
|
if (entity.start < clause.start || entity.end > clause.end) return [];
|
||||||
|
const utensilId = this._utensilIdByUid?.get(entity.uid);
|
||||||
|
// Same drift guard as `techStepId` above.
|
||||||
|
return utensilId === undefined
|
||||||
|
? []
|
||||||
|
: [{ utensilId, start: entity.start, end: entity.end }];
|
||||||
|
});
|
||||||
|
|
||||||
matches.push({
|
matches.push({
|
||||||
techStepId,
|
techStepId,
|
||||||
start: span.start,
|
start: span.start,
|
||||||
end: span.end,
|
end: span.end,
|
||||||
contextStart: clause.start,
|
contextStart: clause.start,
|
||||||
contextEnd: clause.end,
|
contextEnd: clause.end,
|
||||||
|
ingredients,
|
||||||
|
utensils,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -428,17 +479,13 @@ export class TechStepClassifierService {
|
||||||
locale: string,
|
locale: string,
|
||||||
): Promise<TechStepClauseClassification[]> {
|
): Promise<TechStepClauseClassification[]> {
|
||||||
try {
|
try {
|
||||||
await this._ensureTrained();
|
await this._ensureTechStepIdsLoaded();
|
||||||
if (description.trim().length === 0) return [];
|
if (description.trim().length === 0) return [];
|
||||||
|
|
||||||
const nerResult = await this._manager.process(locale, description);
|
const nerResult = await intentServiceClient.process(locale, description);
|
||||||
const candidates: TechniqueCandidate[] = nerResult.entities
|
const candidates: TechniqueCandidate[] = nerResult.entities
|
||||||
.filter((entity) => entity.type === "enum")
|
.filter((entity) => entity.kind === "technique")
|
||||||
.map((entity) => ({
|
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
|
||||||
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[] = [];
|
||||||
|
|
@ -456,15 +503,14 @@ export class TechStepClassifierService {
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const result = await this._manager.process(locale, clauseText);
|
const result = await intentServiceClient.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,
|
intentUid: result.intent,
|
||||||
score: intentUid === null ? 0 : result.score,
|
score: result.intent === null ? 0 : result.score,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
|
|
@ -506,8 +552,8 @@ export class TechStepClassifierService {
|
||||||
const clauseText = description.slice(clause.start, clause.end).trim();
|
const clauseText = description.slice(clause.start, clause.end).trim();
|
||||||
if (clauseText.length === 0) return clause.anchor?.uid ?? null;
|
if (clauseText.length === 0) return clause.anchor?.uid ?? null;
|
||||||
|
|
||||||
const result = await this._manager.process(locale, clauseText);
|
const result = await intentServiceClient.process(locale, clauseText);
|
||||||
if (result.intent !== "None" && result.score >= CONFIDENCE_THRESHOLD) {
|
if (result.intent !== null && result.score >= CONFIDENCE_THRESHOLD) {
|
||||||
return result.intent;
|
return result.intent;
|
||||||
}
|
}
|
||||||
return clause.anchor?.uid ?? null;
|
return clause.anchor?.uid ?? null;
|
||||||
|
|
@ -517,52 +563,56 @@ export class TechStepClassifierService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trains `_manager` from {@link TECH_STEP_TRAINING_DATA} and resolves the
|
* Resolves the `uid -> TechStep.id` lookup exactly once — memoized on
|
||||||
* `uid -> TechStep.id` lookup, both exactly once — memoized on
|
* `_techStepIdsLoaded` so a burst of concurrent calls (several steps of
|
||||||
* `_trained` so a burst of concurrent calls (several steps of the same
|
* the same recipe save, awaited via the same event loop tick) all await
|
||||||
* recipe save, awaited via the same event loop tick) all await the one
|
* the one in-flight DB query rather than each firing their own.
|
||||||
* in-flight training pass rather than each kicking off their own.
|
|
||||||
*/
|
*/
|
||||||
private async _ensureTrained(): Promise<void> {
|
private async _ensureTechStepIdsLoaded(): Promise<void> {
|
||||||
if (this._trained === undefined) {
|
if (this._techStepIdsLoaded === undefined) {
|
||||||
this._trained = this._train();
|
this._techStepIdsLoaded = this._loadTechStepIds();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this._trained;
|
await this._techStepIdsLoaded;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A failed training pass must be retried by the *next* call, not
|
// A failed load must be retried by the *next* call, not leave every
|
||||||
// leave every future call permanently rejecting against a stale
|
// future call permanently rejecting against a stale failed promise.
|
||||||
// failed promise.
|
this._techStepIdsLoaded = undefined;
|
||||||
this._trained = undefined;
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _train(): Promise<void> {
|
private async _loadTechStepIds(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
|
const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
|
||||||
this._techStepIdByUid = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
|
this._techStepIdByUid = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see matchTechStepSpans()'s catch comment above
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const entry of TECH_STEP_TRAINING_DATA) {
|
/** `Utensil.key -> id` counterpart of {@link _ensureTechStepIdsLoaded} — same memoize-once-retry-on-failure shape. */
|
||||||
for (const [locale, data] of [
|
private async _ensureUtensilIdsLoaded(): Promise<void> {
|
||||||
["fr", entry.fr],
|
if (this._utensilIdsLoaded === undefined) {
|
||||||
["en", entry.en],
|
this._utensilIdsLoaded = this._loadUtensilIds();
|
||||||
] as const) {
|
}
|
||||||
if (data.synonyms.length > 0) {
|
try {
|
||||||
this._manager.addNamedEntityText(entry.uid, entry.uid, [locale], data.synonyms);
|
await this._utensilIdsLoaded;
|
||||||
}
|
} catch (err) {
|
||||||
for (const utterance of data.utterances) {
|
this._utensilIdsLoaded = undefined;
|
||||||
this._manager.addDocument(locale, utterance, entry.uid);
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await this._manager.train();
|
private async _loadUtensilIds(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const utensils = await prisma.utensil.findMany({ select: { id: true, key: true } });
|
||||||
|
this._utensilIdByUid = new Map(utensils.map((utensil) => [utensil.key, utensil.id]));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see matchTechStepSpans()'s catch comment above
|
throw err; // see matchTechStepSpans()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single shared instance — training is expensive enough (a few hundred ms) that every caller must reuse the one already-trained model, never spin up their own. */
|
/** Single shared instance — every caller reuses the one memoized `TechStep.key -> id` lookup rather than re-querying the DB. The actual model training (expensive — a couple of minutes, both locales combined) happens entirely inside `services/tech-step-intent-service`'s own startup, not here — see that service's `_TRAINING_ITERATIONS`. */
|
||||||
export const techStepClassifier = new TechStepClassifierService();
|
export const techStepClassifier = new TechStepClassifierService();
|
||||||
|
|
|
||||||
|
|
@ -1,911 +0,0 @@
|
||||||
/**
|
|
||||||
* Training corpus for {@link TechStepClassifierService} (`tech-step-matcher.ts`)
|
|
||||||
* — one entry per `TechStep` (`uid` matches `reference-seed-data.ts`'s
|
|
||||||
* `TECH_STEPS`, which still owns the reference `TechStep` rows themselves;
|
|
||||||
* this file replaces `TECH_STEPS[].mappings`' regex expressions as the
|
|
||||||
* *matching* data source).
|
|
||||||
*
|
|
||||||
* Two distinct kinds of content per technique/locale, feeding two distinct
|
|
||||||
* mechanisms of the classifier (see that file's doc comment for why both
|
|
||||||
* are needed):
|
|
||||||
*
|
|
||||||
* - `synonyms` — short literal words/set phrases, fed to node-nlp's NER
|
|
||||||
* (enum entities). Mechanically equivalent to the old regexes' verb-form
|
|
||||||
* alternations, just spelled out as plain words instead of a pattern
|
|
||||||
* (node-nlp's own stemmer/fuzzy matching already covers minor
|
|
||||||
* conjugation/typo variance that the regexes had to enumerate by hand).
|
|
||||||
* Used only to find *candidate* technique mentions and cut a step into
|
|
||||||
* clauses around them — never the final answer on their own.
|
|
||||||
* - `utterances` — full example clauses, fed to node-nlp's NLP Manager as
|
|
||||||
* training documents for the intent classifier. Deliberately mixes
|
|
||||||
* keyword-anchored phrasings (reinforces the obvious case) with
|
|
||||||
* paraphrases that never use the technique's own verb at all (e.g.
|
|
||||||
* "jusqu'à ce que le beurre ait disparu" for `melt`) — this second kind
|
|
||||||
* is what actually delivers on "comprendre le sens, pas juste les mots
|
|
||||||
* clés" (see the PR this file was introduced in): a clause reaching the
|
|
||||||
* classifier gets labeled by what it's trained to recognize as *meaning*
|
|
||||||
* this technique, not by which literal word triggered its extraction.
|
|
||||||
*
|
|
||||||
* Kept as static in-code data (not DB rows, unlike the old
|
|
||||||
* `TechStepMapping` table) because nothing needs to query/edit it at
|
|
||||||
* runtime — it only ever feeds one thing, the classifier's one-time
|
|
||||||
* training pass (see `TechStepClassifierService._ensureTrained`) — same
|
|
||||||
* reasoning `INGREDIENT_LABELS_EN` (`packages/shared`) is a plain object,
|
|
||||||
* not a database table.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** One technique's matching data for one locale — see this file's doc comment for what each list feeds. */
|
|
||||||
export interface TechStepLocaleTrainingData {
|
|
||||||
synonyms: string[];
|
|
||||||
utterances: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One technique's full training entry — `uid` must match a `TECH_STEPS[].uid` in `reference-seed-data.ts`. */
|
|
||||||
export interface TechStepTrainingEntry {
|
|
||||||
uid: string;
|
|
||||||
fr: TechStepLocaleTrainingData;
|
|
||||||
en: TechStepLocaleTrainingData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
|
|
||||||
{
|
|
||||||
uid: "cook",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"cuire",
|
|
||||||
"cuisez",
|
|
||||||
"cuisant",
|
|
||||||
"cuisson",
|
|
||||||
"cuit",
|
|
||||||
"cuite",
|
|
||||||
"cuites",
|
|
||||||
"cuits",
|
|
||||||
"cuisiner",
|
|
||||||
"cuisinez",
|
|
||||||
"cuisiné",
|
|
||||||
"cuisinée",
|
|
||||||
"faire cuire",
|
|
||||||
"laisser cuire",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"faire cuire à feu moyen",
|
|
||||||
"laisser cuire jusqu'à ce que ce soit prêt",
|
|
||||||
"la cuisson dure environ dix minutes",
|
|
||||||
"jusqu'à ce que la viande ne soit plus rose au centre",
|
|
||||||
"poursuivre la cuisson à couvert",
|
|
||||||
// Two real recipe clauses found misclassified (as `preheat` and
|
|
||||||
// `panFry` respectively, both above the confidence threshold) once
|
|
||||||
// real, longer, comma-heavy sentences started reaching the
|
|
||||||
// classifier — neither error came from a missing keyword (both
|
|
||||||
// clauses' own NER anchor, "laisser cuire"/"faire cuire", was
|
|
||||||
// already right), just the classifier's low-heat/occasional-
|
|
||||||
// stirring phrasing not resembling anything short and clean-cut it
|
|
||||||
// had actually been trained on.
|
|
||||||
"baisser le feu et laisser cuire à découvert encore un quart d'heure",
|
|
||||||
"faire cuire à feu doux en remuant de temps en temps",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "cooked through"/"cooking through" — both are word-prefix
|
|
||||||
// extensions of "cooked"/"cooking" above, so any text containing them
|
|
||||||
// matches BOTH the short and long form as separate overlapping NER
|
|
||||||
// candidates, corrupting clause-splitting (confirmed via "It should
|
|
||||||
// be cooking through evenly", which spuriously grew a second,
|
|
||||||
// wrongly-classified `roast` candidate). See this pattern flagged
|
|
||||||
// throughout the file wherever it was found — the fix is always to
|
|
||||||
// drop the longer, redundant form rather than keep both.
|
|
||||||
synonyms: ["cook", "cooks", "cooked", "cooking"],
|
|
||||||
utterances: [
|
|
||||||
"cook over medium heat",
|
|
||||||
"cook until done",
|
|
||||||
"cooking takes about ten minutes",
|
|
||||||
"until no longer pink in the middle",
|
|
||||||
"continue cooking covered",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "fry",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"frire",
|
|
||||||
"frit",
|
|
||||||
"frite",
|
|
||||||
"frites",
|
|
||||||
"friture",
|
|
||||||
"faire frire",
|
|
||||||
"faites frire",
|
|
||||||
"bain de friture",
|
|
||||||
"huile de friture",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"faire frire dans l'huile chaude",
|
|
||||||
"plonger dans la friture",
|
|
||||||
"jusqu'à ce que ce soit doré et croustillant à l'extérieur",
|
|
||||||
"l'huile doit être bien chaude avant d'y plonger les morceaux",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "frying oil" — a word-prefix extension of "frying" above (see
|
|
||||||
// the `cook` entry's comment for why that duplicates/corrupts NER
|
|
||||||
// candidates; here it was even worse, misclassifying as `preheat`).
|
|
||||||
synonyms: ["fry", "fries", "fried", "frying", "deep fry", "deep-fried", "deep frying"],
|
|
||||||
utterances: [
|
|
||||||
"fry in hot oil",
|
|
||||||
"deep fry until golden",
|
|
||||||
"until crisp and golden on the outside",
|
|
||||||
"the oil should be very hot before adding the pieces",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "melt",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"fondre",
|
|
||||||
"fondu",
|
|
||||||
"fondue",
|
|
||||||
"fondues",
|
|
||||||
"faire fondre",
|
|
||||||
"faites fondre",
|
|
||||||
// Also a plausible way to say "melt" (heating something — usually
|
|
||||||
// a fat — until it liquefies), not just a `preheat` phrasing —
|
|
||||||
// restores what the regex-based system anchored on before this
|
|
||||||
// pipeline replaced it.
|
|
||||||
"faire chauffer",
|
|
||||||
"faites chauffer",
|
|
||||||
"liquéfier",
|
|
||||||
"liquéfiez",
|
|
||||||
"liquéfié",
|
|
||||||
"faire liquéfier",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"faire fondre le beurre",
|
|
||||||
"jusqu'à ce que le beurre ait disparu dans la poêle",
|
|
||||||
"le beurre doit être complètement liquide",
|
|
||||||
"laisser le fromage devenir tout liquide sur feu doux",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["melt", "melts", "melted", "melting", "liquefy", "liquefied"],
|
|
||||||
utterances: [
|
|
||||||
"melt the butter",
|
|
||||||
"until the butter has completely disappeared into the pan",
|
|
||||||
"the butter should be fully liquid",
|
|
||||||
"let the cheese turn completely liquid over low heat",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "deglaze",
|
|
||||||
fr: {
|
|
||||||
// NOT "déglacer la poêle"/"déglacer le fond de cuisson" — both are
|
|
||||||
// word-prefix extensions of "déglacer" above (see `cook`'s comment
|
|
||||||
// for why that duplicates NER candidates).
|
|
||||||
synonyms: ["déglacer", "déglacez", "déglacé", "déglacée", "déglaçage"],
|
|
||||||
utterances: [
|
|
||||||
"déglacer avec le vin blanc",
|
|
||||||
"verser le vin dans la poêle chaude pour décoller les sucs",
|
|
||||||
"gratter les sucs de cuisson au fond de la casserole avec un peu de bouillon",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "deglaze the pan" — a word-prefix extension of "deglaze" above
|
|
||||||
// (see `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["deglaze", "deglazes", "deglazed", "deglazing", "lift the browned bits"],
|
|
||||||
utterances: [
|
|
||||||
"deglaze with white wine",
|
|
||||||
"pour the wine into the hot pan to lift the browned bits",
|
|
||||||
"scrape up the browned bits at the bottom of the pan with a splash of stock",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "simmer",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"mijoter",
|
|
||||||
"mijotez",
|
|
||||||
"mijote",
|
|
||||||
"mijotant",
|
|
||||||
"mijoté",
|
|
||||||
"frémir",
|
|
||||||
"frémissant",
|
|
||||||
"frémissante",
|
|
||||||
"à petit feu",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"laisser mijoter à feu doux",
|
|
||||||
"faire mijoter pendant une heure",
|
|
||||||
"de petites bulles doivent remonter doucement à la surface",
|
|
||||||
"laisser cuire tout doucement à couvert pendant longtemps",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "simmering gently" — a word-prefix extension of "simmering"
|
|
||||||
// above (see `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["simmer", "simmers", "simmered", "simmering", "gentle simmer", "low simmer"],
|
|
||||||
utterances: [
|
|
||||||
"let it simmer over low heat",
|
|
||||||
"simmer for one hour",
|
|
||||||
"small bubbles should gently rise to the surface",
|
|
||||||
"let it cook very gently, covered, for a long time",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "boil",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"bouillir",
|
|
||||||
"bouillant",
|
|
||||||
"bouillie",
|
|
||||||
"bouillies",
|
|
||||||
"ébullition",
|
|
||||||
"porter à ébullition",
|
|
||||||
"gros bouillons",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"porter à ébullition",
|
|
||||||
"faire bouillir l'eau",
|
|
||||||
"de grosses bulles doivent agiter la surface avec force",
|
|
||||||
"jusqu'à ce que ça bouillonne franchement",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "boiling point" — a word-prefix extension of "boiling" above
|
|
||||||
// (see `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["boil", "boils", "boiled", "boiling", "rolling boil"],
|
|
||||||
utterances: [
|
|
||||||
"bring to a boil",
|
|
||||||
"boil the water",
|
|
||||||
"large bubbles should be vigorously breaking the surface",
|
|
||||||
"until it's rolling vigorously",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "roast",
|
|
||||||
fr: {
|
|
||||||
// NOT "rôti au four" — a word-prefix extension of "rôti" above (see
|
|
||||||
// `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["rôtir", "rôti", "rôtie", "rôties", "rôtis", "rôtissage"],
|
|
||||||
utterances: [
|
|
||||||
"faire rôtir la volaille entière",
|
|
||||||
"le rôti doit dorer uniformément de tous les côtés",
|
|
||||||
"cuire la pièce de viande entière au four à chaleur sèche",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["roast", "roasts", "roasted", "roasting", "oven-roast", "oven roasted"],
|
|
||||||
utterances: [
|
|
||||||
"roast the whole bird",
|
|
||||||
"it should brown evenly on every side",
|
|
||||||
"cook the whole piece of meat in dry oven heat",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "grill",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"griller",
|
|
||||||
"grillez",
|
|
||||||
"grillé",
|
|
||||||
"grillée",
|
|
||||||
"grillées",
|
|
||||||
"grillade",
|
|
||||||
"grillades",
|
|
||||||
"barbecue",
|
|
||||||
"au barbecue",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"faire griller sur la grille du barbecue",
|
|
||||||
"marquer les steaks sur une plaque brûlante",
|
|
||||||
"des traces de quadrillage doivent apparaître à la cuisson",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["grill", "grills", "grilled", "grilling", "barbecue", "char-grill", "charbroiled"],
|
|
||||||
utterances: [
|
|
||||||
"grill on the barbecue rack",
|
|
||||||
"sear the steaks on a scorching-hot plate",
|
|
||||||
"char marks should appear as it cooks",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "panFry",
|
|
||||||
fr: {
|
|
||||||
// Deliberately NOT "poêlé"/"poêlée"/"poêlés" here, despite reading
|
|
||||||
// like natural panFry vocabulary: node-nlp's French stemmer reduces
|
|
||||||
// them to the same root as the bare noun "poêle" (a pan), so
|
|
||||||
// registering them made every plain mention of "poêle" — e.g.
|
|
||||||
// `preheat`'s own "la poêle" — a false-positive panFry candidate too.
|
|
||||||
// Found via the "jusqu'à ce que le beurre ait disparu dans la poêle"
|
|
||||||
// regression test, which unexpectedly grew a spurious panFry match.
|
|
||||||
synonyms: ["sauter", "sautez", "sauté", "sautée", "sautées", "sautant", "à la poêle"],
|
|
||||||
utterances: [
|
|
||||||
"faire sauter les légumes à la poêle",
|
|
||||||
"saisir rapidement à feu vif en remuant sans cesse",
|
|
||||||
"faire revenir en remuant vivement dans une poêle très chaude",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: [
|
|
||||||
"sauté",
|
|
||||||
"sauteed",
|
|
||||||
"sautéed",
|
|
||||||
"sauteing",
|
|
||||||
"pan-fry",
|
|
||||||
"pan fried",
|
|
||||||
"pan-fried",
|
|
||||||
"stir-fry",
|
|
||||||
"pan searing",
|
|
||||||
"seared in a pan",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"sauté the vegetables in a pan",
|
|
||||||
"quickly sear over high heat, stirring constantly",
|
|
||||||
"cook briskly, stirring, in a very hot pan",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "blanch",
|
|
||||||
fr: {
|
|
||||||
synonyms: ["blanchir", "blanchissez", "blanchi", "blanchie", "blanchies", "blanchiment"],
|
|
||||||
utterances: [
|
|
||||||
"faire blanchir les légumes deux minutes dans l'eau bouillante",
|
|
||||||
"plonger brièvement dans l'eau bouillante puis directement dans l'eau glacée",
|
|
||||||
"cuire très rapidement à l'eau bouillante avant de stopper la cuisson au froid",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// "parboil" is folded in here rather than kept a separate technique —
|
|
||||||
// in home-cooking usage (as opposed to professional usage, where they
|
|
||||||
// can differ) it names the same "briefly pre-cook in boiling water"
|
|
||||||
// move blanching does.
|
|
||||||
synonyms: [
|
|
||||||
"blanch",
|
|
||||||
"blanches",
|
|
||||||
"blanched",
|
|
||||||
"blanching",
|
|
||||||
"parboil",
|
|
||||||
"parboiled",
|
|
||||||
"parboiling",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"blanch the vegetables for two minutes in boiling water",
|
|
||||||
"briefly plunge into boiling water then straight into ice water",
|
|
||||||
"cook very quickly in boiling water before stopping it cold",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "marinate",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"mariner",
|
|
||||||
"marinez",
|
|
||||||
"mariné",
|
|
||||||
"marinée",
|
|
||||||
"marinées",
|
|
||||||
"marinade",
|
|
||||||
"macérer",
|
|
||||||
"macérez",
|
|
||||||
"macération",
|
|
||||||
"faire mariner",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"laisser mariner la viande toute la nuit au réfrigérateur",
|
|
||||||
"faire tremper dans la sauce plusieurs heures avant cuisson pour parfumer",
|
|
||||||
"laisser reposer dans le mélange d'huile et d'épices avant de cuisiner",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "marinating for" — a word-prefix extension of "marinating"
|
|
||||||
// above (see `cook`'s comment for why that duplicates NER candidates
|
|
||||||
// — here it was even worse, misclassifying as `simmer`).
|
|
||||||
synonyms: [
|
|
||||||
"marinate",
|
|
||||||
"marinates",
|
|
||||||
"marinated",
|
|
||||||
"marinating",
|
|
||||||
"marinade",
|
|
||||||
"soak in the marinade",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"let the meat marinate overnight in the fridge",
|
|
||||||
"soak in the sauce for several hours before cooking to flavor it",
|
|
||||||
"let it sit in the oil and spice mixture before cooking",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "chop",
|
|
||||||
fr: {
|
|
||||||
// NOT "hacher grossièrement" — a word-prefix extension of "hacher"
|
|
||||||
// above (see `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: [
|
|
||||||
"hacher",
|
|
||||||
"hachez",
|
|
||||||
"haché",
|
|
||||||
"hachée",
|
|
||||||
"hachées",
|
|
||||||
"hachis",
|
|
||||||
"couper en morceaux",
|
|
||||||
"tailler en morceaux",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"hacher finement les oignons",
|
|
||||||
"couper en tout petits morceaux irréguliers au couteau",
|
|
||||||
"réduire les herbes en petits fragments avant de les ajouter",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "chop coarsely" — a word-prefix extension of "chop" above (see
|
|
||||||
// `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["chop", "chops", "chopped", "chopping", "roughly chop", "coarsely chopped"],
|
|
||||||
utterances: [
|
|
||||||
"finely chop the onions",
|
|
||||||
"cut into small, uneven pieces with a knife",
|
|
||||||
"break the herbs down into small bits before adding them",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "peel",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"éplucher",
|
|
||||||
"épluchez",
|
|
||||||
"épluché",
|
|
||||||
"épluchée",
|
|
||||||
"épluchées",
|
|
||||||
"épluchage",
|
|
||||||
"peler",
|
|
||||||
"pelez",
|
|
||||||
"pelé",
|
|
||||||
"pelée",
|
|
||||||
"pelées",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"éplucher les pommes de terre",
|
|
||||||
"retirer la peau des carottes avec un économe",
|
|
||||||
"ôter la pelure du fruit avant de le couper",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["peel", "peels", "peeled", "peeling", "pare", "pared", "paring"],
|
|
||||||
utterances: [
|
|
||||||
"peel the potatoes",
|
|
||||||
"remove the skin from the carrots with a peeler",
|
|
||||||
"take the skin off the fruit before cutting it",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "mince",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"émincer",
|
|
||||||
"émincez",
|
|
||||||
"émincé",
|
|
||||||
"émincée",
|
|
||||||
"émincées",
|
|
||||||
"ciseler",
|
|
||||||
"ciselez",
|
|
||||||
"ciselé",
|
|
||||||
"ciselée",
|
|
||||||
"ciselées",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"émincer l'oignon en fines lamelles",
|
|
||||||
"couper en très fines tranches régulières",
|
|
||||||
"détailler en lamelles aussi fines que possible",
|
|
||||||
// Without this, a short clause naming a different vegetable —
|
|
||||||
// "Émincer les tomates" — scored just above `melt`'s confidence
|
|
||||||
// threshold instead (a training-set-composition side effect of
|
|
||||||
// adding utterances elsewhere in this same pass, found by the full
|
|
||||||
// regression suite). A second example anchored on a different noun
|
|
||||||
// widens `mince`'s own region enough to reclaim it.
|
|
||||||
"émincer les tomates en fines rondelles",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "mince finely" — a word-prefix extension of "mince" above (see
|
|
||||||
// `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["mince", "minces", "minced", "mincing", "thinly slice", "finely mince"],
|
|
||||||
utterances: [
|
|
||||||
"mince the onion into thin strips",
|
|
||||||
"cut into very thin, even slices",
|
|
||||||
"slice into strips as thin as possible",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "mix",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"mélanger",
|
|
||||||
"mélangez",
|
|
||||||
"mélangé",
|
|
||||||
"mélangée",
|
|
||||||
"mélangées",
|
|
||||||
"mélange",
|
|
||||||
"brasser",
|
|
||||||
"brassez",
|
|
||||||
"amalgamer",
|
|
||||||
"amalgamez",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"mélanger tous les ingrédients dans un saladier",
|
|
||||||
"combiner le sucre et la farine ensemble",
|
|
||||||
"remuer jusqu'à obtenir une préparation homogène",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: [
|
|
||||||
"mix",
|
|
||||||
"mixes",
|
|
||||||
"mixed",
|
|
||||||
"mixing",
|
|
||||||
"combine",
|
|
||||||
"combined",
|
|
||||||
"blend",
|
|
||||||
"blended",
|
|
||||||
"blending",
|
|
||||||
"stir together",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"mix all the ingredients in a bowl",
|
|
||||||
"combine the sugar and flour together",
|
|
||||||
"stir until the mixture is smooth and even",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "whisk",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"fouetter",
|
|
||||||
"fouettez",
|
|
||||||
"fouetté",
|
|
||||||
"fouettée",
|
|
||||||
"fouettées",
|
|
||||||
"au fouet",
|
|
||||||
"battre au fouet",
|
|
||||||
"monter au fouet",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"fouetter les œufs et le sucre",
|
|
||||||
"battre vigoureusement au fouet jusqu'à ce que ça blanchisse",
|
|
||||||
"travailler énergiquement pour incorporer de l'air au mélange",
|
|
||||||
// Without these, "Fouetter les blancs en neige" misclassified as
|
|
||||||
// `foldIn` — its own training utterance below also happens to say
|
|
||||||
// "les blancs en neige", and node-nlp's intent classifier leaned on
|
|
||||||
// that shared noun phrase over the actual verb. The exact phrase
|
|
||||||
// itself is needed (not just a paraphrase of it) — a longer,
|
|
||||||
// differently-worded utterance alone wasn't enough to outweigh
|
|
||||||
// `foldIn`'s own close phrasing.
|
|
||||||
"fouetter les blancs en neige",
|
|
||||||
"fouetter les blancs en neige jusqu'à ce qu'ils soient fermes",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["whisk", "whisks", "whisked", "whisking", "beat", "whip", "whipped", "whipping"],
|
|
||||||
utterances: [
|
|
||||||
"whisk the eggs and sugar",
|
|
||||||
"beat vigorously with a whisk until pale",
|
|
||||||
"work it briskly to whip air into the mixture",
|
|
||||||
"whisk the egg whites until stiff peaks form",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "foldIn",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"incorporer",
|
|
||||||
"incorporez",
|
|
||||||
"incorporé",
|
|
||||||
"incorporée",
|
|
||||||
"incorporées",
|
|
||||||
// NOT "incorporer délicatement" — it's a superstring of "incorporer"
|
|
||||||
// above, so both would match the same text and hand
|
|
||||||
// `splitIntoClauses` two overlapping candidates for one mention
|
|
||||||
// (found via "Incorporer délicatement la farine" producing two
|
|
||||||
// duplicate matches instead of one).
|
|
||||||
"mélanger délicatement",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"incorporer délicatement les blancs en neige",
|
|
||||||
"ajouter en soulevant doucement la masse pour ne pas casser les bulles",
|
|
||||||
"mélanger tout doucement de bas en haut pour garder l'air emprisonné",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["fold in", "folds in", "folded in", "folding in", "gently fold", "fold gently"],
|
|
||||||
utterances: [
|
|
||||||
"gently fold in the beaten egg whites",
|
|
||||||
"add by gently lifting the batter so you don't knock the air out",
|
|
||||||
"very gently stir from the bottom up to keep the air trapped in",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "setAside",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"réserver",
|
|
||||||
"réservez",
|
|
||||||
"réservé",
|
|
||||||
"réservée",
|
|
||||||
"réservées",
|
|
||||||
"mettre de côté",
|
|
||||||
"laisser de côté",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"réserver au frais en attendant",
|
|
||||||
"mettre de côté pour plus tard",
|
|
||||||
"laisser attendre sur le plan de travail pendant la préparation du reste",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["set aside", "sets aside", "setting aside", "set it aside", "reserve", "reserved"],
|
|
||||||
utterances: [
|
|
||||||
"set aside in the fridge for now",
|
|
||||||
"put it aside for later",
|
|
||||||
"let it wait on the counter while you prepare the rest",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "season",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"assaisonner",
|
|
||||||
"assaisonnez",
|
|
||||||
"assaisonné",
|
|
||||||
"assaisonnée",
|
|
||||||
"assaisonnement",
|
|
||||||
"relever",
|
|
||||||
"relevez",
|
|
||||||
"épicer",
|
|
||||||
"épicez",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"assaisonner avec du sel et du poivre",
|
|
||||||
"rectifier le goût en ajoutant des épices",
|
|
||||||
"ajouter du sel selon votre goût avant de servir",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["season", "seasons", "seasoned", "seasoning", "spice it up", "add seasoning"],
|
|
||||||
utterances: [
|
|
||||||
"season with salt and pepper",
|
|
||||||
"adjust the taste by adding spices",
|
|
||||||
"add salt to taste before serving",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "drain",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"égoutter",
|
|
||||||
"égouttez",
|
|
||||||
"égoutté",
|
|
||||||
"égouttée",
|
|
||||||
"égouttées",
|
|
||||||
"essorer",
|
|
||||||
"essorez",
|
|
||||||
"essoré",
|
|
||||||
"essorée",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"égoutter les pâtes dans une passoire",
|
|
||||||
"verser dans une passoire pour retirer l'eau de cuisson",
|
|
||||||
"laisser l'excédent d'eau s'écouler avant de servir",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
synonyms: ["drain", "drains", "drained", "draining", "strain", "strained", "straining"],
|
|
||||||
utterances: [
|
|
||||||
"drain the pasta in a colander",
|
|
||||||
"pour into a colander to remove the cooking water",
|
|
||||||
"let the excess water run off before serving",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "brown",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"faire revenir",
|
|
||||||
"faites revenir",
|
|
||||||
"faire dorer",
|
|
||||||
"faites dorer",
|
|
||||||
"colorer",
|
|
||||||
"colorez",
|
|
||||||
"faire colorer",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"faire revenir les oignons dans l'huile chaude",
|
|
||||||
"faire dorer la viande sur toutes les faces",
|
|
||||||
"saisir jusqu'à ce que la surface prenne une belle couleur caramel",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// Verb forms only (not bare "brown"), same reasoning the old regex
|
|
||||||
// doc comment gave — a bare "brown" false-positives on ingredient
|
|
||||||
// descriptions like "brown sugar"/"brown rice", which never get to
|
|
||||||
// the classifier since they're not step text, but keeping the
|
|
||||||
// synonym itself anchored costs nothing and stays consistent.
|
|
||||||
synonyms: ["browned", "browning"],
|
|
||||||
utterances: [
|
|
||||||
"brown the onions in hot oil",
|
|
||||||
"brown the meat on every side",
|
|
||||||
"sear until the surface turns a deep caramel color",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "rest",
|
|
||||||
fr: {
|
|
||||||
synonyms: ["reposer", "laisser reposer", "laissez reposer", "temps de repos"],
|
|
||||||
utterances: [
|
|
||||||
"laisser reposer la pâte trente minutes",
|
|
||||||
"laisser la viande se détendre hors du four avant de la découper",
|
|
||||||
"attendre quelques minutes avant de servir pour que les jus se répartissent",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// Anchored to "let ... rest"/"rest for" rather than bare "rest",
|
|
||||||
// same false-positive reasoning as `brown` above ("the rest of the").
|
|
||||||
synonyms: ["let it rest", "let them rest", "resting for", "rested for", "resting time"],
|
|
||||||
utterances: [
|
|
||||||
"let the dough rest for thirty minutes",
|
|
||||||
"let the meat relax outside the oven before carving it",
|
|
||||||
"wait a few minutes before serving so the juices redistribute",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "preheat",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"préchauffer",
|
|
||||||
"préchauffez",
|
|
||||||
"préchauffé",
|
|
||||||
"préchauffée",
|
|
||||||
// A pan already described as hot ("poêle chaude") implies it's
|
|
||||||
// been preheated, without the verb itself — the classic "Dans une
|
|
||||||
// poêle chaude, faire chauffer une noix de beurre" case (both
|
|
||||||
// `preheat` and `melt` in one instruction).
|
|
||||||
"poêle chaude",
|
|
||||||
"préchauffage",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"préchauffer le four à 180 degrés",
|
|
||||||
"mettre le four à chauffer avant d'y placer le plat",
|
|
||||||
"allumer le four à l'avance pour qu'il soit à température",
|
|
||||||
// A pan gets preheated too, not just an oven — without an example
|
|
||||||
// like this, "poêle" (which also appears throughout `panFry`'s own
|
|
||||||
// training utterances) biased the classifier toward `panFry` for
|
|
||||||
// any preheating clause that happens to mention a pan, found while
|
|
||||||
// testing against the classic "Préchauffer la poêle, puis faire
|
|
||||||
// fondre le beurre" case.
|
|
||||||
"préchauffer la poêle avant d'y verser l'huile",
|
|
||||||
"faire chauffer la poêle à vide quelques minutes",
|
|
||||||
// "poêle" + "feu vif" together still read as `panFry` (the act of
|
|
||||||
// actually cooking something in it) rather than `preheat` (getting
|
|
||||||
// it hot beforehand, nothing in it yet) without an example this
|
|
||||||
// close to that exact wording — found via "mettre la poêle sur feu
|
|
||||||
// vif" (no food mentioned at all) still classifying as panFry.
|
|
||||||
"mettre la poêle vide sur feu vif avant d'ajouter quoi que ce soit",
|
|
||||||
"mettre la poêle sur feu vif",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "preheating time" — a word-prefix extension of "preheating"
|
|
||||||
// above (see `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["preheat", "preheats", "preheated", "preheating", "hot pan"],
|
|
||||||
utterances: [
|
|
||||||
"preheat the oven to 180 degrees",
|
|
||||||
"turn the oven on to heat up before putting the dish in",
|
|
||||||
"switch the oven on ahead of time so it's up to temperature",
|
|
||||||
"preheat the pan before adding the oil",
|
|
||||||
"heat the empty pan for a few minutes first",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "bake",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"cuire au four",
|
|
||||||
"cuisson au four",
|
|
||||||
"enfourner",
|
|
||||||
"enfournez",
|
|
||||||
"au four",
|
|
||||||
"enfourné",
|
|
||||||
"enfournée",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"enfourner pendant quarante-cinq minutes",
|
|
||||||
"mettre au four jusqu'à ce que ce soit doré",
|
|
||||||
"cuire dans le four préchauffé jusqu'à ce que la surface soit ferme",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "baked in the oven" — a word-prefix extension of "baked" above
|
|
||||||
// (see `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["bake", "bakes", "baked", "baking", "in the oven", "oven-baked"],
|
|
||||||
utterances: [
|
|
||||||
"bake for forty-five minutes",
|
|
||||||
"put it in the oven until golden",
|
|
||||||
"cook in the preheated oven until the surface is firm",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "plate",
|
|
||||||
fr: {
|
|
||||||
// NOT "dressage de l'assiette" — a word-prefix extension of
|
|
||||||
// "dressage" above (see `cook`'s comment for why that duplicates NER
|
|
||||||
// candidates).
|
|
||||||
synonyms: ["dresser", "dressez", "dressage", "disposer dans l'assiette"],
|
|
||||||
utterances: [
|
|
||||||
"dresser harmonieusement dans les assiettes",
|
|
||||||
"disposer joliment sur l'assiette avant de servir",
|
|
||||||
"présenter avec soin au centre de l'assiette",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "plate up"/"plated nicely" — both are word-prefix extensions of
|
|
||||||
// "plate"/"plated" above (see `cook`'s comment for why that
|
|
||||||
// duplicates NER candidates).
|
|
||||||
synonyms: ["plate", "plates", "plated", "plating"],
|
|
||||||
utterances: [
|
|
||||||
"plate it up nicely",
|
|
||||||
"arrange it neatly on the plate before serving",
|
|
||||||
"present it carefully in the center of the plate",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: "coat",
|
|
||||||
fr: {
|
|
||||||
synonyms: [
|
|
||||||
"napper",
|
|
||||||
"nappez",
|
|
||||||
"nappé",
|
|
||||||
"nappée",
|
|
||||||
"nappées",
|
|
||||||
"nappage",
|
|
||||||
"enrober",
|
|
||||||
"enrobez",
|
|
||||||
"enrobé",
|
|
||||||
"enrobée",
|
|
||||||
"enrobées",
|
|
||||||
],
|
|
||||||
utterances: [
|
|
||||||
"napper le gâteau de chocolat fondu",
|
|
||||||
"recouvrir uniformément d'une fine couche de sauce",
|
|
||||||
"verser la sauce par-dessus pour bien enrober",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
// NOT "coat evenly" — a word-prefix extension of "coat" above (see
|
|
||||||
// `cook`'s comment for why that duplicates NER candidates).
|
|
||||||
synonyms: ["coat", "coats", "coated", "coating", "dredge", "dredged", "dredging"],
|
|
||||||
utterances: [
|
|
||||||
"coat the cake with melted chocolate",
|
|
||||||
"cover evenly with a thin layer of sauce",
|
|
||||||
"pour the sauce over it so it's well covered",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -84,6 +84,75 @@ 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
|
||||||
|
|
@ -125,6 +194,20 @@ 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
|
||||||
|
|
@ -142,13 +225,28 @@ export async function renumberStepTechSteps(
|
||||||
* `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. A no-op if none matches
|
* matching existing entry is deleted outright (cascading away any
|
||||||
* (nothing to remove).
|
* ingredient/utensil metadata attached to it, auto or manual — nothing
|
||||||
|
* left to attach metadata to once the technique itself is gone). A
|
||||||
|
* no-op if none matches (nothing to remove).
|
||||||
|
*
|
||||||
|
* `metadata`, when given (only ever alongside a real `correctedTechStepId`
|
||||||
|
* — enforced by `submitTechStepCorrectionSchema`, not re-checked here),
|
||||||
|
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil` row on
|
||||||
|
* this occurrence — `source: "auto"` (the classifier's own detection) and
|
||||||
|
* any earlier `"manual"` set alike — with the newly-submitted one. This is
|
||||||
|
* "le manuel remplace tout" (confirmed with the user): the resolved
|
||||||
|
* `order` this technique ends up at (whichever branch above produced it)
|
||||||
|
* is the same `techStepOrder` both metadata tables key on, so the same
|
||||||
|
* `deleteMany` + `createMany` pair below is correct whether this call just
|
||||||
|
* updated an existing row (which may already carry auto-detected
|
||||||
|
* metadata) or created a brand new one (nothing to delete yet — a no-op
|
||||||
|
* `deleteMany`, not a special case).
|
||||||
*
|
*
|
||||||
* Runs inside the same transaction {@link submitTechStepCorrection} uses
|
* Runs inside the same transaction {@link submitTechStepCorrection} uses
|
||||||
* for the audit-trail insert, so a request never leaves the two effects
|
* for the audit-trail insert, so a request never leaves any of these
|
||||||
* (the permanent correction record, the live sequence change) only
|
* effects (the permanent correction record, the live sequence change, the
|
||||||
* partially applied.
|
* metadata replacement) only partially applied.
|
||||||
*/
|
*/
|
||||||
async function applyManualCorrection(
|
async function applyManualCorrection(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
|
|
@ -156,6 +254,7 @@ 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 } });
|
||||||
|
|
||||||
|
|
@ -172,9 +271,12 @@ 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: target.order } },
|
where: { stepId_order: { stepId, order } },
|
||||||
data: {
|
data: {
|
||||||
techStepId: correctedTechStepId,
|
techStepId: correctedTechStepId,
|
||||||
start: span.start,
|
start: span.start,
|
||||||
|
|
@ -185,18 +287,48 @@ 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: nextOrder,
|
order,
|
||||||
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 } } });
|
||||||
}
|
}
|
||||||
|
|
@ -232,9 +364,12 @@ 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` fall outside the step's current `description` (it may
|
* `start`/`end` (the correction's own span, or any of
|
||||||
* have been edited since the user last saw it). `404 TECH_STEP_NOT_FOUND`
|
* `input.ingredients`/`input.utensils`' own spans) fall outside the
|
||||||
* if either tech-step id doesn't exist.
|
* step's current `description` (it may have been edited since the user
|
||||||
|
* last saw it). `404 TECH_STEP_NOT_FOUND`/`404 INGREDIENT_NOT_FOUND`/
|
||||||
|
* `404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND` if any referenced id
|
||||||
|
* doesn't exist.
|
||||||
*/
|
*/
|
||||||
export async function submitTechStepCorrection(
|
export async function submitTechStepCorrection(
|
||||||
recipeId: number,
|
recipeId: number,
|
||||||
|
|
@ -246,18 +381,32 @@ export async function submitTechStepCorrection(
|
||||||
try {
|
try {
|
||||||
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
|
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
|
||||||
|
|
||||||
if (input.start >= step.descriptionLength || input.end > step.descriptionLength) {
|
const spans = [
|
||||||
throw new HttpError(
|
{ start: input.start, end: input.end },
|
||||||
400,
|
...(input.ingredients ?? []),
|
||||||
ErrorCode.INVALID_CORRECTION_SPAN,
|
...(input.utensils ?? []),
|
||||||
`Span [${input.start}, ${input.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
|
];
|
||||||
);
|
for (const span of spans) {
|
||||||
|
if (span.start >= step.descriptionLength || span.end > step.descriptionLength) {
|
||||||
|
throw new HttpError(
|
||||||
|
400,
|
||||||
|
ErrorCode.INVALID_CORRECTION_SPAN,
|
||||||
|
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const techStepIds = [input.previousTechStepId, input.correctedTechStepId].filter(
|
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({
|
||||||
|
|
@ -278,12 +427,46 @@ 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: { techStep: true },
|
include: {
|
||||||
|
techStep: true,
|
||||||
|
ingredients: {
|
||||||
|
include: {
|
||||||
|
ingredient: {
|
||||||
|
include: {
|
||||||
|
allergies: { include: { allergy: { include: { category: true } } } },
|
||||||
|
diets: { include: { diet: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
unit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
utensils: { include: { utensil: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { correction: createdCorrection, techSteps: freshTechSteps };
|
return { correction: createdCorrection, techSteps: freshTechSteps };
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,29 @@ function recipeInclude(viewerId: number) {
|
||||||
steps: {
|
steps: {
|
||||||
orderBy: { order: "asc" },
|
orderBy: { order: "asc" },
|
||||||
include: {
|
include: {
|
||||||
techSteps: { orderBy: { order: "asc" }, include: { techStep: true } },
|
techSteps: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: {
|
||||||
|
techStep: true,
|
||||||
|
// Same `allergies`/`diets` nesting as this function's own
|
||||||
|
// top-level `ingredients` include above — reused by
|
||||||
|
// `toIngredientView` so a mentioned ingredient resolves to the
|
||||||
|
// exact same `IngredientView` shape as the recipe's main
|
||||||
|
// ingredient list, not a second, thinner shape.
|
||||||
|
ingredients: {
|
||||||
|
include: {
|
||||||
|
ingredient: {
|
||||||
|
include: {
|
||||||
|
allergies: { include: { allergy: { include: { category: true } } } },
|
||||||
|
diets: { include: { diet: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
unit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
utensils: { include: { utensil: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
diets: { include: { diet: true } },
|
diets: { include: { diet: true } },
|
||||||
|
|
@ -56,11 +78,13 @@ function recipeInclude(viewerId: number) {
|
||||||
type RecipeWithDetails = Prisma.RecipeGetPayload<{
|
type RecipeWithDetails = Prisma.RecipeGetPayload<{
|
||||||
include: ReturnType<typeof recipeInclude>;
|
include: ReturnType<typeof recipeInclude>;
|
||||||
}>;
|
}>;
|
||||||
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
/** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */
|
||||||
type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
|
export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
||||||
|
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. */
|
||||||
|
export type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
|
||||||
|
|
||||||
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. */
|
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */
|
||||||
function toUnitView(unit: UnitWithDetails): UnitView {
|
export function toUnitView(unit: UnitWithDetails): UnitView {
|
||||||
return {
|
return {
|
||||||
id: unit.id,
|
id: unit.id,
|
||||||
key: unit.key,
|
key: unit.key,
|
||||||
|
|
@ -69,8 +93,8 @@ function toUnitView(unit: UnitWithDetails): UnitView {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */
|
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same flattening as `reference.service.ts`'s `getIngredients`. Exported — see {@link toUnitView}'s doc comment, same reuse by `shopping-list.service.ts`. */
|
||||||
function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
|
export function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
|
||||||
return {
|
return {
|
||||||
id: ingredient.id,
|
id: ingredient.id,
|
||||||
key: ingredient.key,
|
key: ingredient.key,
|
||||||
|
|
@ -143,7 +167,8 @@ 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 } = stepTechStep;
|
const { start, end, contextStart, contextEnd, techStep, source, ingredients, utensils } =
|
||||||
|
stepTechStep;
|
||||||
if (start === null || end === null) continue;
|
if (start === null || end === null) continue;
|
||||||
views.push({
|
views.push({
|
||||||
techStep: { id: techStep.id, key: techStep.key },
|
techStep: { id: techStep.id, key: techStep.key },
|
||||||
|
|
@ -157,6 +182,22 @@ 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;
|
||||||
|
|
@ -551,6 +592,22 @@ async function createRecipeInternal(
|
||||||
contextStart: match.contextStart,
|
contextStart: match.contextStart,
|
||||||
contextEnd: match.contextEnd,
|
contextEnd: match.contextEnd,
|
||||||
order,
|
order,
|
||||||
|
ingredients: {
|
||||||
|
create: match.ingredients.map((ingredient) => ({
|
||||||
|
ingredientId: ingredient.ingredientId,
|
||||||
|
quantity: ingredient.quantity,
|
||||||
|
unitId: ingredient.unitId,
|
||||||
|
start: ingredient.start,
|
||||||
|
end: ingredient.end,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
utensils: {
|
||||||
|
create: match.utensils.map((utensil) => ({
|
||||||
|
utensilId: utensil.utensilId,
|
||||||
|
start: utensil.start,
|
||||||
|
end: utensil.end,
|
||||||
|
})),
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
getSources,
|
getSources,
|
||||||
getTechSteps,
|
getTechSteps,
|
||||||
getUnits,
|
getUnits,
|
||||||
|
getUtensils,
|
||||||
} from "./reference.service.js";
|
} from "./reference.service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -55,6 +56,13 @@ referenceRouter.get(
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
referenceRouter.get(
|
||||||
|
"/utensils",
|
||||||
|
wrapAsyncHandler(async (_req, res) => {
|
||||||
|
res.status(200).json(await getUtensils());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
referenceRouter.get(
|
referenceRouter.get(
|
||||||
"/sources",
|
"/sources",
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
wrapAsyncHandler(async (_req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
SourceView,
|
SourceView,
|
||||||
TechStepView,
|
TechStepView,
|
||||||
UnitView,
|
UnitView,
|
||||||
|
UtensilView,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
|
|
||||||
|
|
@ -85,6 +86,19 @@ export async function getTechSteps(): Promise<TechStepView[]> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All reference cooking utensils, ordered by key (see {@link getDiets} for
|
||||||
|
* why) — small, static list (see `reference-seed-data.ts`'s `UTENSILS`),
|
||||||
|
* same bare `id`/`key` shape as {@link getTechSteps}.
|
||||||
|
*/
|
||||||
|
export async function getUtensils(): Promise<UtensilView[]> {
|
||||||
|
try {
|
||||||
|
return await prisma.utensil.findMany({ orderBy: { key: "asc" } });
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see getDiets()'s catch comment above
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every implemented recipe source, ordered by name (not `key` — unlike
|
* Every implemented recipe source, ordered by name (not `key` — unlike
|
||||||
* every other reference catalog, `name` here *is* the display string a
|
* every other reference catalog, `name` here *is* the display string a
|
||||||
|
|
|
||||||
36
apps/api/src/modules/shopping-list/shopping-list.routes.ts
Normal file
36
apps/api/src/modules/shopping-list/shopping-list.routes.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { parseDateOnly } from "@batch-cooking/date-tools";
|
||||||
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
|
import { ErrorCode, getShoppingListSchema } from "@batch-cooking/shared";
|
||||||
|
import { Router } from "express";
|
||||||
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||||
|
import { getShoppingListForDate } from "./shopping-list.service.js";
|
||||||
|
|
||||||
|
/** Router mounted at `/shopping-list` in app.ts. */
|
||||||
|
export const shoppingListRouter = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the authenticated user's household's shopping list for the week
|
||||||
|
* covering `?date=` (`YYYY-MM-DD`) — every ingredient line of every recipe
|
||||||
|
* planned that week, summed (see {@link getShoppingListForDate}). Always
|
||||||
|
* `200`, never `null` — no household or nothing planned that week both
|
||||||
|
* come back as a normal `ShoppingListView` with an empty `items` array.
|
||||||
|
*/
|
||||||
|
shoppingListRouter.get(
|
||||||
|
"/",
|
||||||
|
requireAuth,
|
||||||
|
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||||
|
const input = getShoppingListSchema.parse(req.query);
|
||||||
|
const date = parseDateOnly(input.date);
|
||||||
|
if (date === null) {
|
||||||
|
throw new HttpError(
|
||||||
|
400,
|
||||||
|
ErrorCode.VALIDATION_ERROR,
|
||||||
|
`Not a real calendar date: ${input.date}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shoppingList = await getShoppingListForDate(res.locals.userProfile.houseId, date);
|
||||||
|
res.status(200).json(shoppingList);
|
||||||
|
}),
|
||||||
|
);
|
||||||
154
apps/api/src/modules/shopping-list/shopping-list.service.ts
Normal file
154
apps/api/src/modules/shopping-list/shopping-list.service.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
|
||||||
|
import type {
|
||||||
|
IngredientView,
|
||||||
|
ShoppingListItemView,
|
||||||
|
ShoppingListView,
|
||||||
|
UnitView,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "../../db/prisma.js";
|
||||||
|
import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
|
||||||
|
|
||||||
|
/** Prisma `include` for a `Planning` query that needs, for every item, just enough of its recipe to compute a shopping list — `portions` (to scale `RecipeIngredient.quantity`) and the ingredient lines themselves, each resolved the same way `recipe.service.ts`'s own `recipeInclude` resolves them (so {@link toIngredientView}/{@link toUnitView} can be reused as-is). Deliberately narrower than a full `RecipeView` fetch — steps/diets/favorites are never read here. */
|
||||||
|
function shoppingListPlanningInclude() {
|
||||||
|
return {
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
recipe: {
|
||||||
|
select: {
|
||||||
|
portions: true,
|
||||||
|
ingredients: {
|
||||||
|
include: {
|
||||||
|
ingredient: {
|
||||||
|
include: {
|
||||||
|
allergies: { include: { allergy: { include: { category: true } } } },
|
||||||
|
diets: { include: { diet: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
unit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.PlanningInclude;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlanningWithIngredients = Prisma.PlanningGetPayload<{
|
||||||
|
include: ReturnType<typeof shoppingListPlanningInclude>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. */
|
||||||
|
interface RunningTotal {
|
||||||
|
ingredient: IngredientView;
|
||||||
|
unit: UnitView;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sums every ingredient line across `items`, each scaled by that planning
|
||||||
|
* item's own portion count relative to its recipe's as-written yield
|
||||||
|
* (`RecipeIngredient.quantity × PlanningItem.portions / Recipe.portions`,
|
||||||
|
* see `PlanningItem.portions`'s doc comment in schema.prisma for why the
|
||||||
|
* two can differ). Grouped by `(ingredientId, unitId)` — **not** just
|
||||||
|
* `ingredientId` — since summing across units isn't implemented yet (see
|
||||||
|
* `ShoppingListItemView`'s doc comment): the same ingredient requested in
|
||||||
|
* two different units stays two separate lines rather than silently
|
||||||
|
* guessing a conversion. Pure/synchronous, factored out from
|
||||||
|
* {@link getShoppingListForDate} so the aggregation itself is testable
|
||||||
|
* without a database round-trip.
|
||||||
|
*/
|
||||||
|
function aggregateShoppingList(items: PlanningWithIngredients["items"]): ShoppingListItemView[] {
|
||||||
|
const totals = new Map<string, RunningTotal>();
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const scale = item.portions / item.recipe.portions;
|
||||||
|
for (const recipeIngredient of item.recipe.ingredients) {
|
||||||
|
const key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`;
|
||||||
|
const addedQuantity = Number(recipeIngredient.quantity) * scale;
|
||||||
|
|
||||||
|
const existing = totals.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity += addedQuantity;
|
||||||
|
} else {
|
||||||
|
totals.set(key, {
|
||||||
|
ingredient: toIngredientView(recipeIngredient.ingredient),
|
||||||
|
unit: toUnitView(recipeIngredient.unit),
|
||||||
|
quantity: addedQuantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic order (by the ingredient's stable `key`, not its id —
|
||||||
|
// insertion order would otherwise depend on which recipe happened to be
|
||||||
|
// read first) — the frontend re-sorts by translated label/aisle for
|
||||||
|
// display, this is just so two identical plannings always produce the
|
||||||
|
// same JSON.
|
||||||
|
return [...totals.values()].sort((a, b) => a.ingredient.key.localeCompare(b.ingredient.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the household's shopping list for the week covering `date` —
|
||||||
|
* every ingredient line of every recipe planned that week, aggregated (see
|
||||||
|
* {@link aggregateShoppingList}). `date` is whatever the caller wants "that
|
||||||
|
* week" to mean, same convention as `planning.service.ts`'s
|
||||||
|
* `getPlanningForDate` (a caller-parsed `?date=`, not necessarily a
|
||||||
|
* Monday).
|
||||||
|
*
|
||||||
|
* Unlike `getPlanningForDate`, this **never** returns `null` — no household
|
||||||
|
* and "no planning covers this week yet" both degrade to an empty `items`
|
||||||
|
* array on an otherwise normal `ShoppingListView` (the week's date range is
|
||||||
|
* always computable from `date` alone, even with nothing planned in it),
|
||||||
|
* rather than a separate "nothing to show" state the frontend would have to
|
||||||
|
* branch on.
|
||||||
|
*/
|
||||||
|
export async function getShoppingListForDate(
|
||||||
|
houseId: number | null,
|
||||||
|
date: DateTime,
|
||||||
|
): Promise<ShoppingListView> {
|
||||||
|
try {
|
||||||
|
const weekStart = getWeekStart(toDateOnly(date));
|
||||||
|
const weekFinish = weekStart.plus({ days: 6 });
|
||||||
|
const emptyList: ShoppingListView = {
|
||||||
|
startDate: weekStart.toJSDate().toISOString(),
|
||||||
|
finishDate: weekFinish.toJSDate().toISOString(),
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (houseId === null) {
|
||||||
|
return emptyList;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same "covering range" lookup as getPlanningForDate — see that
|
||||||
|
// function's doc comment for why this compares against a UTC-midnight
|
||||||
|
// JS Date rather than `weekStart`/`weekFinish` directly.
|
||||||
|
const dateOnly = toDateOnly(date).toJSDate();
|
||||||
|
const planning = await prisma.planning.findFirst({
|
||||||
|
where: {
|
||||||
|
houseId,
|
||||||
|
startDate: { lte: dateOnly },
|
||||||
|
finishDate: { gte: dateOnly },
|
||||||
|
},
|
||||||
|
orderBy: { startDate: "desc" },
|
||||||
|
include: shoppingListPlanningInclude(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!planning) {
|
||||||
|
return emptyList;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: planning.startDate.toISOString(),
|
||||||
|
finishDate: planning.finishDate.toISOString(),
|
||||||
|
items: aggregateShoppingList(planning.items),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||||
|
// already logs it, see `error-logger.ts`) is what actually handles it,
|
||||||
|
// this service layer just isn't allowed a bare `await` per the repo's
|
||||||
|
// async/try-catch convention.
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,7 +27,7 @@ import { RecipeSourceError } from "../../lib/recipe-sources/recipe-source-errors
|
||||||
import { getRecipeSource } from "../../lib/recipe-sources/recipe-source-registry.js";
|
import { getRecipeSource } from "../../lib/recipe-sources/recipe-source-registry.js";
|
||||||
import { getHouseSourceIds } from "../house/house.service.js";
|
import { getHouseSourceIds } from "../house/house.service.js";
|
||||||
import { createImportedRecipe } from "../recipe/recipe.service.js";
|
import { createImportedRecipe } from "../recipe/recipe.service.js";
|
||||||
import { getIngredients, getUnits } from "../reference/reference.service.js";
|
import { getIngredients, getUnits, getUtensils } from "../reference/reference.service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browsing, previewing, and importing a household's *enabled* external
|
* Browsing, previewing, and importing a household's *enabled* external
|
||||||
|
|
@ -190,9 +190,14 @@ export async function previewSourceItem(
|
||||||
unitCatalog,
|
unitCatalog,
|
||||||
adapter.locale,
|
adapter.locale,
|
||||||
);
|
);
|
||||||
const [ingredientViews, unitViews] = await Promise.all([getIngredients(), getUnits()]);
|
const [ingredientViews, unitViews, utensilViews] = await Promise.all([
|
||||||
|
getIngredients(),
|
||||||
|
getUnits(),
|
||||||
|
getUtensils(),
|
||||||
|
]);
|
||||||
const ingredientById = new Map(ingredientViews.map((view) => [view.id, view]));
|
const ingredientById = new Map(ingredientViews.map((view) => [view.id, view]));
|
||||||
const unitById = new Map(unitViews.map((view) => [view.id, view]));
|
const unitById = new Map(unitViews.map((view) => [view.id, view]));
|
||||||
|
const utensilById = new Map(utensilViews.map((view) => [view.id, view]));
|
||||||
|
|
||||||
// A source's raw ingredient lines aren't deduplicated by the matcher —
|
// A source's raw ingredient lines aren't deduplicated by the matcher —
|
||||||
// two different lines (e.g. "Egg Yolks"/"Eggs") can resolve to the same
|
// two different lines (e.g. "Egg Yolks"/"Eggs") can resolve to the same
|
||||||
|
|
@ -231,6 +236,30 @@ 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`/`tech-step-training-data.ts`), the same way
|
* (`tech-step-matcher.ts`, delegating to `services/tech-step-intent-service`),
|
||||||
* `updateRecipe` does when a user resaves a recipe through the UI —
|
* the same way `updateRecipe` does when a user resaves a recipe through the UI —
|
||||||
* always `"fr"` (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`; there's
|
* always `"fr"` (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`; there's
|
||||||
* no persisted per-recipe locale to recover for a step that already
|
* 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
|
||||||
|
|
|
||||||
101
apps/api/src/scripts/calibrate-tech-step-threshold.ts
Normal file
101
apps/api/src/scripts/calibrate-tech-step-threshold.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { prisma } from "../db/prisma.js";
|
||||||
|
import { TECH_STEP_EVAL_DATASET } from "../lib/recipe-matching/tech-step-eval-dataset.js";
|
||||||
|
import {
|
||||||
|
computeTechStepMetrics,
|
||||||
|
type TechStepEvalOutcome,
|
||||||
|
} from "../lib/recipe-matching/tech-step-evaluator.js";
|
||||||
|
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Candidate thresholds to sweep, `0.05` to `0.95` in `0.05` steps — fine
|
||||||
|
* enough to find a good value without an unreasonable number of full
|
||||||
|
* `TECH_STEP_EVAL_DATASET` passes (each threshold only needs one
|
||||||
|
* {@link techStepClassifier.classifyClauses} call per eval case, not a
|
||||||
|
* retrain — see this file's own doc comment for why).
|
||||||
|
*/
|
||||||
|
const CANDIDATE_THRESHOLDS = Array.from({ length: 19 }, (_, i) => Math.round((i + 1) * 5) / 100);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-off maintainer tool for recalibrating `CONFIDENCE_THRESHOLD`
|
||||||
|
* (`tech-step-matcher.ts`) after a change to the underlying intent
|
||||||
|
* classifier — most notably, the migration from `node-nlp` to
|
||||||
|
* `services/tech-step-intent-service` (spaCy): a different model produces a
|
||||||
|
* differently-shaped confidence score distribution, so a threshold tuned
|
||||||
|
* against the old classifier has no reason to still be the right cutoff for
|
||||||
|
* the new one.
|
||||||
|
*
|
||||||
|
* Reuses `techStepClassifier.classifyClauses` — already public, and
|
||||||
|
* deliberately *not* threshold-applied (see that method's own doc comment)
|
||||||
|
* — to get every eval case's raw `{anchorUid, intentUid, score}` per clause
|
||||||
|
* exactly once, then replays `_classifyClause`'s own decision rule
|
||||||
|
* (`intentUid` if confident enough, `anchorUid` otherwise) locally in this
|
||||||
|
* script for every candidate threshold. This is what makes a full sweep
|
||||||
|
* cheap: one classifier pass per eval case regardless of how many
|
||||||
|
* thresholds are being compared, rather than one full pass *per threshold*.
|
||||||
|
*
|
||||||
|
* Prints a threshold -> precision/recall/F1 table and the threshold that
|
||||||
|
* maximizes aggregate F1 — does **not** edit `tech-step-matcher.ts` itself.
|
||||||
|
* A maintainer reads the table, updates `CONFIDENCE_THRESHOLD` by hand (with
|
||||||
|
* an updated doc comment recording what run/F1 the new value was calibrated
|
||||||
|
* against, same as the existing comment's own format), then re-runs
|
||||||
|
* `retrain-tech-steps.ts` to confirm the change clears `MIN_OVERALL_F1`.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
*
|
||||||
|
* pnpm --filter api exec tsx src/scripts/calibrate-tech-step-threshold.ts
|
||||||
|
*/
|
||||||
|
async function calibrateTechStepThreshold(): Promise<void> {
|
||||||
|
console.info(`Classifying ${TECH_STEP_EVAL_DATASET.length} eval case(s)...`);
|
||||||
|
|
||||||
|
// One classifier pass per eval case, all clauses' raw verdicts kept
|
||||||
|
// alongside the case's own `expectedKeys` — reused for every candidate
|
||||||
|
// threshold in the loop below.
|
||||||
|
const casesWithClauses = await Promise.all(
|
||||||
|
TECH_STEP_EVAL_DATASET.map(async (evalCase) => ({
|
||||||
|
expectedKeys: evalCase.expectedKeys,
|
||||||
|
clauses: await techStepClassifier.classifyClauses(evalCase.description, evalCase.locale),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.info("\nthreshold precision recall f1");
|
||||||
|
let bestThreshold = CANDIDATE_THRESHOLDS[0] ?? 0;
|
||||||
|
let bestF1 = -1;
|
||||||
|
|
||||||
|
for (const threshold of CANDIDATE_THRESHOLDS) {
|
||||||
|
const outcomes: TechStepEvalOutcome[] = casesWithClauses.map(({ expectedKeys, clauses }) => {
|
||||||
|
const actualKeys = clauses
|
||||||
|
// Mirrors `_classifyClause`'s own decision rule exactly (see that
|
||||||
|
// method, `tech-step-matcher.ts`) — the classifier's own verdict
|
||||||
|
// when confident enough, otherwise its clause's NER anchor, `null`
|
||||||
|
// when neither applies (no keyword, no confident classification).
|
||||||
|
.map((clause) =>
|
||||||
|
clause.intentUid !== null && clause.score >= threshold
|
||||||
|
? clause.intentUid
|
||||||
|
: clause.anchorUid,
|
||||||
|
)
|
||||||
|
.filter((key): key is string => key !== null);
|
||||||
|
return { expectedKeys, actualKeys };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { overall } = computeTechStepMetrics(outcomes);
|
||||||
|
console.info(
|
||||||
|
`${threshold.toFixed(2)} ${overall.precision.toFixed(3)} ${overall.recall.toFixed(3)} ${overall.f1.toFixed(3)}`,
|
||||||
|
);
|
||||||
|
if (overall.f1 > bestF1) {
|
||||||
|
bestF1 = overall.f1;
|
||||||
|
bestThreshold = threshold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`\nBest aggregate F1 ${bestF1.toFixed(3)} at threshold ${bestThreshold.toFixed(2)} — update CONFIDENCE_THRESHOLD in tech-step-matcher.ts by hand if this differs from the current value.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
calibrateTechStepThreshold()
|
||||||
|
.then(() => prisma.$disconnect())
|
||||||
|
.catch(async (err) => {
|
||||||
|
console.error(err);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -6,14 +6,15 @@ 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
|
||||||
* `tech-step-training-data.ts` and running `retrain-tech-steps.ts` — this
|
* `services/tech-step-intent-service/intent_service/training_data.py` and
|
||||||
* script never writes anything, purely a read-only report to stdout:
|
* running `retrain-tech-steps.ts` — this script never writes anything,
|
||||||
|
* purely a read-only report to stdout:
|
||||||
*
|
*
|
||||||
* pnpm --filter api exec tsx src/scripts/list-pending-training-suggestions.ts
|
* 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
|
||||||
* `TECH_STEP_TRAINING_DATA` is read together, matching how that file
|
* `training_data.py`'s `TECH_STEP_TRAINING_DATA` is read together, matching
|
||||||
* itself is organized (one block per technique).
|
* how that file 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,10 +28,15 @@ 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 `tech-step-training-data.ts`
|
* 1. A maintainer has already hand-edited
|
||||||
* (informed by `list-pending-training-suggestions.ts`'s report), and
|
* `services/tech-step-intent-service/intent_service/training_data.py`
|
||||||
|
* (informed by `list-pending-training-suggestions.ts`'s report),
|
||||||
* decided which `TechStepTrainingSuggestion` ids they incorporated
|
* decided which `TechStepTrainingSuggestion` ids they incorporated
|
||||||
* (`--applied=`) or explicitly discarded (`--rejected=`).
|
* (`--applied=`) or explicitly discarded (`--rejected=`), **and
|
||||||
|
* restarted `tech-step-intent-service`** so it retrains from the
|
||||||
|
* edited corpus — that service only ever trains once, at its own
|
||||||
|
* startup (see its README), so this script's eval gate below is
|
||||||
|
* meaningless against a service still running the old corpus.
|
||||||
* 2. This script re-runs the F1 regression gate
|
* 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,22 +9,45 @@ import { registerAllRecipeSources } from "./sources/index.js";
|
||||||
// doesn't happen inside app.ts/createServer() itself.
|
// doesn't happen inside app.ts/createServer() itself.
|
||||||
registerAllRecipeSources();
|
registerAllRecipeSources();
|
||||||
|
|
||||||
// Trains the tech-step classifier (and pays node-nlp's own one-time lazy
|
/**
|
||||||
// setup cost — see `TechStepClassifierService.warmUp`) before accepting
|
* Trains the tech-step classifier (a `POST /v1/train` round-trip per locale
|
||||||
// any traffic, so the first real recipe save/preview isn't the one stuck
|
* to `services/tech-step-intent-service` — see
|
||||||
// waiting several seconds for it.
|
* `TechStepClassifierService.warmUp`) before accepting any traffic, so the
|
||||||
try {
|
* first real recipe save/preview isn't the one stuck waiting for it.
|
||||||
await techStepClassifier.warmUp();
|
*
|
||||||
} catch (err) {
|
* Retried with exponential backoff: in Docker Compose, `app`'s own
|
||||||
// Not fatal to startup — a failed warm-up just means the *next* call
|
* `depends_on: tech-step-intent-service: condition: service_healthy`
|
||||||
// retries training itself (see `_ensureTrained`'s own retry-on-failure
|
* (`docker-compose.yml`) already means that service is up by the time this
|
||||||
// comment), same graceful-degrade posture as everywhere else training
|
* runs, but native dev (`pnpm dev:api`, no Compose ordering at all) can
|
||||||
// failures surface. Still worth a loud log: this shouldn't normally fail.
|
* easily start this before the intent service has finished loading its
|
||||||
logger.error("Tech-step classifier warm-up failed", {
|
* spaCy models — a transient connection failure here shouldn't need a
|
||||||
error: err instanceof Error ? err.message : String(err),
|
* manual restart. Still non-fatal after every attempt is exhausted: the
|
||||||
});
|
* *next* real call retries training itself (see `_ensureTrained`'s own
|
||||||
|
* retry-on-failure comment), same graceful-degrade posture as before this
|
||||||
|
* retry loop existed.
|
||||||
|
*/
|
||||||
|
async function warmUpTechStepClassifier(): Promise<void> {
|
||||||
|
const maxAttempts = 5;
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
await techStepClassifier.warmUp();
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt === maxAttempts) {
|
||||||
|
logger.error("Tech-step classifier warm-up failed after retries", {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
attempts: attempt,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delayMs = 1000 * 2 ** (attempt - 1);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await warmUpTechStepClassifier();
|
||||||
|
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
|
|
||||||
server.listen(env.PORT, () => {
|
server.listen(env.PORT, () => {
|
||||||
|
|
|
||||||
50
apps/api/src/types/node-nlp.d.ts
vendored
50
apps/api/src/types/node-nlp.d.ts
vendored
|
|
@ -1,50 +0,0 @@
|
||||||
/**
|
|
||||||
* Minimal ambient typing for `node-nlp` (no official/DefinitelyTyped types
|
|
||||||
* exist for it) — declares only the `NlpManager` surface
|
|
||||||
* `tech-step-matcher.ts` actually calls, verified against the real
|
|
||||||
* package (v4.27.0) rather than the library's full documented API, which
|
|
||||||
* this repo doesn't use the rest of.
|
|
||||||
*/
|
|
||||||
declare module "node-nlp" {
|
|
||||||
/** Constructor options this repo passes — `NlpManager` accepts more, only what's used here is typed. */
|
|
||||||
export interface NlpManagerOptions {
|
|
||||||
languages?: string[];
|
|
||||||
forceNER?: boolean;
|
|
||||||
nlu?: { log?: boolean };
|
|
||||||
ner?: { threshold?: number };
|
|
||||||
/** Defaults to `true` — persists the trained model to `modelFileName` (default `model.nlp`, in `process.cwd()`). See `tech-step-matcher.ts`'s own constructor comment for why this repo always sets it `false`. */
|
|
||||||
autoSave?: boolean;
|
|
||||||
/** Defaults to `true` — loads from `modelFileName` instead of training fresh if that file already exists. Always `false` here, same reasoning as `autoSave`. */
|
|
||||||
autoLoad?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One entity `NlpManager.process`'s result reports — see `tech-step-matcher.ts`'s own `NerEntity` for the subset this repo reads. */
|
|
||||||
export interface NlpEntity {
|
|
||||||
entity: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
type: string;
|
|
||||||
accuracy?: number;
|
|
||||||
sourceText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `NlpManager.process`'s result — trimmed to the fields this repo reads (the real object carries many more). */
|
|
||||||
export interface NlpProcessResult {
|
|
||||||
intent: string;
|
|
||||||
score: number;
|
|
||||||
entities: NlpEntity[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NlpManager {
|
|
||||||
public constructor(options?: NlpManagerOptions);
|
|
||||||
public addNamedEntityText(
|
|
||||||
entityName: string,
|
|
||||||
optionName: string,
|
|
||||||
languages: string[],
|
|
||||||
texts: string[],
|
|
||||||
): void;
|
|
||||||
public addDocument(locale: string, utterance: string, intent: string): void;
|
|
||||||
public train(): Promise<void>;
|
|
||||||
public process(locale: string, text: string): Promise<NlpProcessResult>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
40
apps/api/test-support/mocha-root-hooks.ts
Normal file
40
apps/api/test-support/mocha-root-hooks.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { techStepClassifier } from "../src/lib/recipe-matching/tech-step-matcher.js";
|
||||||
|
import { resetDatabase } from "./reset-db.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mocha root hook plugin (see `.mocharc.json`'s `require`) — runs once
|
||||||
|
* before every test file's own suites, regardless of load order.
|
||||||
|
*
|
||||||
|
* Warms up `techStepClassifier` here — resolving the `TechStep.key -> id`
|
||||||
|
* lookup from the DB (see `TechStepClassifierService._loadTechStepIds`) —
|
||||||
|
* instead of leaving it to happen lazily on whichever test file Mocha
|
||||||
|
* happens to load first, same as `server.ts` does before the real server
|
||||||
|
* ever accepts traffic. Fast by itself (one DB query, one HTTP call to
|
||||||
|
* `services/tech-step-intent-service`): that service now trains itself
|
||||||
|
* entirely at its own process startup (see its own README), so unlike
|
||||||
|
* before this migration, nothing here waits on a slow training pass — CI's
|
||||||
|
* own "wait for `/health`" step (`.github/workflows/ci.yml`) is what
|
||||||
|
* ensures that service is already fully trained before `pnpm --filter api
|
||||||
|
* test` even starts.
|
||||||
|
*
|
||||||
|
* `resetDatabase()` runs first, deliberately: id resolution needs
|
||||||
|
* `TechStep` rows, and a freshly-migrated (never-seeded) test database has
|
||||||
|
* none yet. Every per-test `beforeEach` in this suite already calls
|
||||||
|
* `resetDatabase()` again before its own test, which is a no-op
|
||||||
|
* duplication of effort but not a correctness problem: `TRUNCATE ...
|
||||||
|
* RESTART IDENTITY` plus deterministic re-seeding (`seedReferenceData`)
|
||||||
|
* assigns the exact same ids every time, so the `uid -> id` map memoized
|
||||||
|
* here from this first reset stays valid for every reset after it.
|
||||||
|
*/
|
||||||
|
export const mochaHooks = {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Mocha's root hook `this` (a Context with `.timeout()`) isn't typed without @types/mocha (not a dependency here) — same untyped-`this` shape already used in tech-step-worker.routes.test.ts.
|
||||||
|
async beforeAll(this: any): Promise<void> {
|
||||||
|
// A little more generous than Mocha's normal 10s per-test default
|
||||||
|
// (`.mocharc.json`) purely for a slower/contended CI runner's first
|
||||||
|
// network round-trip to `services/tech-step-intent-service` — not
|
||||||
|
// because anything here waits on training anymore.
|
||||||
|
this.timeout(30000);
|
||||||
|
await resetDatabase();
|
||||||
|
await techStepClassifier.warmUp();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -3,6 +3,7 @@ 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,
|
||||||
|
|
@ -266,6 +267,100 @@ describe("ingredient-matcher", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("findIngredientMentions", () => {
|
||||||
|
const butter: IngredientMatchEntry = { ingredientId: 1, label: "Butter" };
|
||||||
|
const flour: IngredientMatchEntry = { ingredientId: 2, label: "Flour" };
|
||||||
|
const egg: IngredientMatchEntry = { ingredientId: 3, label: "Egg" };
|
||||||
|
const catalog = [butter, flour, egg];
|
||||||
|
const gram: UnitMatchEntry = { unitId: 1, synonyms: ["g", "gram", "grams"] };
|
||||||
|
const unitCatalog = [gram];
|
||||||
|
|
||||||
|
it("finds a single mention with no quantity or unit", () => {
|
||||||
|
const text = "melt the butter";
|
||||||
|
const mentions = findIngredientMentions(text, catalog, unitCatalog);
|
||||||
|
expect(mentions).to.have.length(1);
|
||||||
|
const [mention] = mentions;
|
||||||
|
expect(mention?.ingredientId).to.equal(butter.ingredientId);
|
||||||
|
expect(text.slice(mention?.start, mention?.end)).to.equal("butter");
|
||||||
|
expect(mention?.quantity).to.equal(null);
|
||||||
|
expect(mention?.unitId).to.equal(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a quantity and unit glued directly to the ingredient ("200g butter")', () => {
|
||||||
|
const text = "add 200g butter";
|
||||||
|
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
|
||||||
|
expect(mention?.ingredientId).to.equal(butter.ingredientId);
|
||||||
|
expect(mention?.quantity).to.equal(200);
|
||||||
|
expect(mention?.unitId).to.equal(gram.unitId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds several mentions in reading order, non-overlapping", () => {
|
||||||
|
const text = "melt the butter then add the flour and an egg";
|
||||||
|
const mentions = findIngredientMentions(text, catalog, unitCatalog);
|
||||||
|
expect(mentions.map((mention) => mention.ingredientId)).to.deep.equal([
|
||||||
|
butter.ingredientId,
|
||||||
|
flour.ingredientId,
|
||||||
|
egg.ingredientId,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case- and accent-insensitive", () => {
|
||||||
|
const text = "MELT THE BUTTER";
|
||||||
|
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
|
||||||
|
expect(mention?.ingredientId).to.equal(butter.ingredientId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an unrelated number earlier in the text (e.g. an oven temperature)", () => {
|
||||||
|
const text = "preheat to 180 degrees then add the egg";
|
||||||
|
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
|
||||||
|
expect(mention?.ingredientId).to.equal(egg.ingredientId);
|
||||||
|
expect(mention?.quantity).to.equal(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array when nothing in the catalog is mentioned", () => {
|
||||||
|
expect(findIngredientMentions("stir well", catalog, unitCatalog)).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array for empty text", () => {
|
||||||
|
expect(findIngredientMentions("", catalog, unitCatalog)).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("locale: fr", () => {
|
||||||
|
const beurre: IngredientMatchEntry = { ingredientId: 10, label: "Beurre" };
|
||||||
|
const farine: IngredientMatchEntry = { ingredientId: 11, label: "Farine" };
|
||||||
|
const frCatalog = [beurre, farine];
|
||||||
|
const gramme: UnitMatchEntry = { unitId: 40, synonyms: ["g", "gr", "gramme", "grammes"] };
|
||||||
|
const cuillereASoupe: UnitMatchEntry = {
|
||||||
|
unitId: 41,
|
||||||
|
synonyms: ["cuillère à soupe", "cuillères à soupe", "càs"],
|
||||||
|
};
|
||||||
|
const frUnitCatalog = [gramme, cuillereASoupe];
|
||||||
|
|
||||||
|
it("resolves a quantity and unit before the ingredient, connected by 'de'", () => {
|
||||||
|
const text = "faire fondre 50g de beurre";
|
||||||
|
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
|
||||||
|
expect(mention?.ingredientId).to.equal(beurre.ingredientId);
|
||||||
|
expect(mention?.quantity).to.equal(50);
|
||||||
|
expect(mention?.unitId).to.equal(gramme.unitId);
|
||||||
|
expect(text.slice(mention?.start, mention?.end)).to.equal("beurre");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a multi-word unit connected by "d\'"', () => {
|
||||||
|
const text = "ajouter 2 cuillères à soupe de farine";
|
||||||
|
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
|
||||||
|
expect(mention?.ingredientId).to.equal(farine.ingredientId);
|
||||||
|
expect(mention?.quantity).to.equal(2);
|
||||||
|
expect(mention?.unitId).to.equal(cuillereASoupe.unitId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is accent-insensitive", () => {
|
||||||
|
const text = "FAIRE FONDRE LE BEURRE";
|
||||||
|
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
|
||||||
|
expect(mention?.ingredientId).to.equal(beurre.ingredientId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("loadIngredientCatalog / loadUnitCatalog", () => {
|
describe("loadIngredientCatalog / loadUnitCatalog", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await resetDatabase();
|
await resetDatabase();
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,9 @@ describe("recipe-translation", () => {
|
||||||
// `translateRecipeSteps` now goes through `techStepClassifier` (a
|
// `translateRecipeSteps` now goes through `techStepClassifier` (a
|
||||||
// trained model, not a pure regex test against a caller-supplied
|
// trained model, not a pure regex test against a caller-supplied
|
||||||
// mapping list — see `tech-step-matcher.ts`), so these tests exercise
|
// mapping list — see `tech-step-matcher.ts`), so these tests exercise
|
||||||
// the real training corpus (`tech-step-training-data.ts`) against a real
|
// the real training corpus (`services/tech-step-intent-service`'s
|
||||||
// `TechStep` catalog rather than synthetic fixtures — same posture
|
// `training_data.py`) against a real `TechStep` catalog rather than
|
||||||
|
// synthetic fixtures — same posture
|
||||||
// `tech-step-matcher.test.ts`'s own `techStepClassifier` describe block
|
// `tech-step-matcher.test.ts`'s own `techStepClassifier` describe block
|
||||||
// takes, for the same reason.
|
// takes, for the same reason.
|
||||||
describe("translateRecipeSteps", () => {
|
describe("translateRecipeSteps", () => {
|
||||||
|
|
|
||||||
|
|
@ -118,13 +118,16 @@ describe("tech-step-matcher", () => {
|
||||||
// `techStepClassifier` is the one shared singleton (see
|
// `techStepClassifier` is the one shared singleton (see
|
||||||
// tech-step-matcher.ts's own doc comment on why) — these tests
|
// tech-step-matcher.ts's own doc comment on why) — these tests
|
||||||
// exercise it against the real training corpus
|
// exercise it against the real training corpus
|
||||||
// (`tech-step-training-data.ts`) and the real seeded `TechStep`
|
// (`services/tech-step-intent-service`'s `training_data.py`) and the
|
||||||
// catalog, rather than synthetic injectable fixtures the old
|
// real seeded `TechStep` catalog, rather than synthetic injectable
|
||||||
// regex-based `matchTechStepSpans(description, mappings)` allowed.
|
// fixtures the old regex-based `matchTechStepSpans(description,
|
||||||
// Training + node-nlp's own one-time per-language setup can take a
|
// mappings)` allowed. Every call round-trips over HTTP to a real,
|
||||||
// few seconds on the very first call in the whole suite (subsequent
|
// locally running `services/tech-step-intent-service` (see that
|
||||||
// calls reuse the same trained model and are fast) — comfortably
|
// service's own README and `apps/api/.env.test`) — that service trains
|
||||||
// inside this suite's default 10s timeout (.mocharc.json).
|
// itself once at its own startup (`test-support/mocha-root-hooks.ts`'s
|
||||||
|
// root hook doesn't wait on it, CI's own "wait for /health" step
|
||||||
|
// already does), so calls here are just a normal HTTP round-trip,
|
||||||
|
// comfortably inside this suite's default 10s timeout (.mocharc.json).
|
||||||
let simmerId: number;
|
let simmerId: number;
|
||||||
let cookId: number;
|
let cookId: number;
|
||||||
let bakeId: number;
|
let bakeId: number;
|
||||||
|
|
@ -132,18 +135,36 @@ describe("tech-step-matcher", () => {
|
||||||
let meltId: number;
|
let meltId: number;
|
||||||
let boilId: number;
|
let boilId: number;
|
||||||
let chopId: number;
|
let chopId: number;
|
||||||
|
// Real seeded catalog entries that also happen to be mentioned by
|
||||||
|
// several fixtures below now that `matchTechStepSpans` also resolves
|
||||||
|
// ingredient/utensil metadata — see `matchTechStepSpans`'s own describe
|
||||||
|
// block for where each of these gets used.
|
||||||
|
let panId: number;
|
||||||
|
let butterId: number;
|
||||||
|
let onionId: number;
|
||||||
|
let walnutsId: number;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await resetDatabase();
|
await resetDatabase();
|
||||||
const [simmer, cook, bake, preheat, melt, boil, chop] = await Promise.all([
|
const [simmer, cook, bake, preheat, melt, boil, chop, pan, butter, onion, walnuts] =
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } }),
|
await Promise.all([
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "cook" } }),
|
prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } }),
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "bake" } }),
|
prisma.techStep.findFirstOrThrow({ where: { key: "cook" } }),
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "preheat" } }),
|
prisma.techStep.findFirstOrThrow({ where: { key: "bake" } }),
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "melt" } }),
|
prisma.techStep.findFirstOrThrow({ where: { key: "preheat" } }),
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "boil" } }),
|
prisma.techStep.findFirstOrThrow({ where: { key: "melt" } }),
|
||||||
prisma.techStep.findFirstOrThrow({ where: { key: "chop" } }),
|
prisma.techStep.findFirstOrThrow({ where: { key: "boil" } }),
|
||||||
]);
|
prisma.techStep.findFirstOrThrow({ where: { key: "chop" } }),
|
||||||
|
prisma.utensil.findFirstOrThrow({ where: { key: "pan" } }),
|
||||||
|
prisma.ingredient.findFirstOrThrow({ where: { key: "butter" } }),
|
||||||
|
prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } }),
|
||||||
|
// "Noix" (walnuts) — turns out to also be a real seeded ingredient
|
||||||
|
// label, and "noix" is literally the French word for "a pat of
|
||||||
|
// butter" ("une noix de beurre") used in one of the fixtures
|
||||||
|
// below, so it's a genuine (if slightly comical) second match
|
||||||
|
// alongside "beurre" in that clause, not a fixture bug.
|
||||||
|
prisma.ingredient.findFirstOrThrow({ where: { key: "walnuts" } }),
|
||||||
|
]);
|
||||||
simmerId = simmer.id;
|
simmerId = simmer.id;
|
||||||
cookId = cook.id;
|
cookId = cook.id;
|
||||||
bakeId = bake.id;
|
bakeId = bake.id;
|
||||||
|
|
@ -151,6 +172,10 @@ describe("tech-step-matcher", () => {
|
||||||
meltId = melt.id;
|
meltId = melt.id;
|
||||||
boilId = boil.id;
|
boilId = boil.id;
|
||||||
chopId = chop.id;
|
chopId = chop.id;
|
||||||
|
panId = pan.id;
|
||||||
|
butterId = butter.id;
|
||||||
|
onionId = onion.id;
|
||||||
|
walnutsId = walnuts.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
|
|
@ -252,7 +277,15 @@ describe("tech-step-matcher", () => {
|
||||||
const text = "Faire mijoter à feu doux";
|
const text = "Faire mijoter à feu doux";
|
||||||
const result = await techStepClassifier.matchTechStepSpans(text, "fr");
|
const result = await techStepClassifier.matchTechStepSpans(text, "fr");
|
||||||
expect(result).to.deep.equal([
|
expect(result).to.deep.equal([
|
||||||
{ techStepId: simmerId, start: 6, end: 13, contextStart: 0, contextEnd: text.length },
|
{
|
||||||
|
techStepId: simmerId,
|
||||||
|
start: 6,
|
||||||
|
end: 13,
|
||||||
|
contextStart: 0,
|
||||||
|
contextEnd: text.length,
|
||||||
|
ingredients: [],
|
||||||
|
utensils: [],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(text.slice(6, 13).toLowerCase()).to.equal("mijoter");
|
expect(text.slice(6, 13).toLowerCase()).to.equal("mijoter");
|
||||||
});
|
});
|
||||||
|
|
@ -302,6 +335,12 @@ describe("tech-step-matcher", () => {
|
||||||
end: 21,
|
end: 21,
|
||||||
contextStart: 0,
|
contextStart: 0,
|
||||||
contextEnd: 22,
|
contextEnd: 22,
|
||||||
|
// "poêle" (the pan) sits inside this very clause — a separate
|
||||||
|
// utensil mention from `preheat`'s own "poêle chaude" keyword
|
||||||
|
// span above, found by the intent service's *other* PhraseMatcher
|
||||||
|
// (see `IntentServiceEntity.kind`).
|
||||||
|
ingredients: [],
|
||||||
|
utensils: [{ utensilId: panId, start: 9, end: 14 }],
|
||||||
});
|
});
|
||||||
expect(result[1]).to.deep.equal({
|
expect(result[1]).to.deep.equal({
|
||||||
techStepId: meltId,
|
techStepId: meltId,
|
||||||
|
|
@ -309,6 +348,15 @@ describe("tech-step-matcher", () => {
|
||||||
end: 37,
|
end: 37,
|
||||||
contextStart: 22,
|
contextStart: 22,
|
||||||
contextEnd: text.length,
|
contextEnd: text.length,
|
||||||
|
// Two mentions in this clause: "noix" (walnuts — also a real
|
||||||
|
// seeded ingredient, and literally the French word this phrase
|
||||||
|
// uses for "a pat of [butter]") *and* "beurre" itself, in
|
||||||
|
// reading order.
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: walnutsId, start: 42, end: 46, quantity: null, unitId: null },
|
||||||
|
{ ingredientId: butterId, start: 50, end: 56, quantity: null, unitId: null },
|
||||||
|
],
|
||||||
|
utensils: [],
|
||||||
});
|
});
|
||||||
expect(text.slice(result[0].start, result[0].end)).to.equal("poêle chaude");
|
expect(text.slice(result[0].start, result[0].end)).to.equal("poêle chaude");
|
||||||
expect(text.slice(result[0].contextStart, result[0].contextEnd)).to.equal(
|
expect(text.slice(result[0].contextStart, result[0].contextEnd)).to.equal(
|
||||||
|
|
@ -330,6 +378,15 @@ describe("tech-step-matcher", () => {
|
||||||
end: text.length,
|
end: text.length,
|
||||||
contextStart: 0,
|
contextStart: 0,
|
||||||
contextEnd: text.length,
|
contextEnd: text.length,
|
||||||
|
// "beurre" and "poêle" are both mentioned in this same
|
||||||
|
// anchor-less clause (there's no literal `melt` keyword here at
|
||||||
|
// all — the whole point of this test, see its own title) —
|
||||||
|
// still resolved, since ingredient/utensil scanning doesn't
|
||||||
|
// depend on the clause having a technique anchor of its own.
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: butterId, start: 18, end: 24, quantity: null, unitId: null },
|
||||||
|
],
|
||||||
|
utensils: [{ utensilId: panId, start: 45, end: 50 }],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
@ -338,10 +395,33 @@ describe("tech-step-matcher", () => {
|
||||||
const text = "Chop the onions finely";
|
const text = "Chop the onions finely";
|
||||||
const result = await techStepClassifier.matchTechStepSpans(text, "en");
|
const result = await techStepClassifier.matchTechStepSpans(text, "en");
|
||||||
expect(result).to.deep.equal([
|
expect(result).to.deep.equal([
|
||||||
{ techStepId: chopId, start: 0, end: 4, contextStart: 0, contextEnd: text.length },
|
{
|
||||||
|
techStepId: chopId,
|
||||||
|
start: 0,
|
||||||
|
end: 4,
|
||||||
|
contextStart: 0,
|
||||||
|
contextEnd: text.length,
|
||||||
|
ingredients: [
|
||||||
|
{ ingredientId: onionId, start: 9, end: 15, quantity: null, unitId: null },
|
||||||
|
],
|
||||||
|
utensils: [],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(text.slice(0, 4)).to.equal("Chop");
|
expect(text.slice(0, 4)).to.equal("Chop");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Quantity+unit extraction itself (the leading-number-before-a-mention
|
||||||
|
// heuristic) is covered in full, deterministically, by
|
||||||
|
// `findIngredientMentions`'s own tests (`ingredient-matcher.test.ts`)
|
||||||
|
// — deliberately not re-exercised here through a brand-new invented
|
||||||
|
// sentence: a novel combination of words the real `textcat` (trained
|
||||||
|
// on a fixed, finite corpus, see `training_data.py`) has never seen
|
||||||
|
// together can land on a confidently-wrong technique for reasons
|
||||||
|
// that have nothing to do with this file's own logic, making such a
|
||||||
|
// test flaky against corpus/threshold changes rather than a
|
||||||
|
// trustworthy regression guard. The two tests above/below already
|
||||||
|
// demonstrate technique+ingredient+utensil co-occurring in one
|
||||||
|
// clause using sentences already proven reliable by this suite.
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,24 @@ 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();
|
||||||
|
|
||||||
|
|
@ -79,7 +97,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 tech-step-training-data.ts) — irrelevant here either way,
|
// (see services/tech-step-intent-service's training_data.py) — irrelevant here either way,
|
||||||
// since this test's whole point is the *manual* addition, not
|
// 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);
|
||||||
|
|
@ -98,7 +116,14 @@ 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: [],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -124,7 +149,14 @@ 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: [],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -245,6 +277,244 @@ 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 } from "../src/db/reference-seed-data.js";
|
import { seedReferenceData, TECH_STEPS, UTENSILS } from "../src/db/reference-seed-data.js";
|
||||||
import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
|
import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
|
||||||
import {
|
import {
|
||||||
clearRecipeSources,
|
clearRecipeSources,
|
||||||
|
|
@ -137,7 +137,9 @@ describe("Reference data", () => {
|
||||||
const res = await request(app).get("/reference/tech-steps");
|
const res = await request(app).get("/reference/tech-steps");
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
expect(res.status).to.equal(200);
|
||||||
expect(res.body).to.have.length(26);
|
// `TECH_STEPS.length` (reference-seed-data.ts), not a hardcoded
|
||||||
|
// number — this catalog has grown since (26 -> 74) and will again.
|
||||||
|
expect(res.body).to.have.length(TECH_STEPS.length);
|
||||||
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer");
|
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer");
|
||||||
expect(res.body[0]).to.have.keys(["id", "key"]);
|
expect(res.body[0]).to.have.keys(["id", "key"]);
|
||||||
});
|
});
|
||||||
|
|
@ -155,7 +157,32 @@ describe("Reference data", () => {
|
||||||
await seedReferenceData(prisma);
|
await seedReferenceData(prisma);
|
||||||
|
|
||||||
const res = await request(app).get("/reference/tech-steps");
|
const res = await request(app).get("/reference/tech-steps");
|
||||||
expect(res.body).to.have.length(26);
|
expect(res.body).to.have.length(TECH_STEPS.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /reference/utensils", () => {
|
||||||
|
it("returns the seeded utensils, no session required", async () => {
|
||||||
|
const res = await request(app).get("/reference/utensils");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.have.length(UTENSILS.length);
|
||||||
|
expect(res.body.map((u: { key: string }) => u.key)).to.include("pan");
|
||||||
|
expect(res.body[0]).to.have.keys(["id", "key"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders utensils alphabetically by key", async () => {
|
||||||
|
const res = await request(app).get("/reference/utensils");
|
||||||
|
|
||||||
|
const keys = res.body.map((u: { key: string }) => u.key);
|
||||||
|
expect(keys).to.deep.equal([...keys].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reseeding is idempotent — no duplicate utensils", async () => {
|
||||||
|
await seedReferenceData(prisma);
|
||||||
|
|
||||||
|
const res = await request(app).get("/reference/utensils");
|
||||||
|
expect(res.body).to.have.length(UTENSILS.length);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
336
apps/api/test/shopping-list.test.ts
Normal file
336
apps/api/test/shopping-list.test.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
import type { DateTime } from "@batch-cooking/date-tools";
|
||||||
|
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
|
||||||
|
import { faker } from "@faker-js/faker";
|
||||||
|
import { expect } from "chai";
|
||||||
|
import request from "supertest";
|
||||||
|
import { createApp } from "../src/app.js";
|
||||||
|
import { prisma } from "../src/db/prisma.js";
|
||||||
|
import { TEST_REFERENCE_DATE } from "../test-support/reference-date.js";
|
||||||
|
import { resetDatabase } from "../test-support/reset-db.js";
|
||||||
|
|
||||||
|
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
|
||||||
|
function buildSignupPayload(): SignupInput {
|
||||||
|
const firstName = faker.person.firstName();
|
||||||
|
const lastName = faker.person.lastName();
|
||||||
|
return {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
||||||
|
password: faker.internet.password({ length: 16 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fixed test "today", as the `YYYY-MM-DD` string `GET /shopping-list`'s `?date=` expects. */
|
||||||
|
function today(): string {
|
||||||
|
return isoDate(TEST_REFERENCE_DATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `toISODate()` only returns `null` for an invalid `DateTime` — never the case for the always-valid values built in this file. */
|
||||||
|
function isoDate(date: DateTime): string {
|
||||||
|
const iso = date.toISODate();
|
||||||
|
if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper");
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helper as `recipe.test.ts`. */
|
||||||
|
async function ingredientId(key: string): Promise<number> {
|
||||||
|
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
|
||||||
|
return ingredient.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a reference unit's id by its `reference-seed-data.ts` uid — same helper as `recipe.test.ts`. */
|
||||||
|
async function unitId(key: string): Promise<number> {
|
||||||
|
const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
|
||||||
|
return unit.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Shopping list", () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /shopping-list", () => {
|
||||||
|
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||||
|
const res = await request(app).get("/shopping-list").query({ date: today() });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(401);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing date with 400 VALIDATION_ERROR", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
|
||||||
|
const res = await agent.get("/shopping-list");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(400);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed date with 400 VALIDATION_ERROR", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
|
||||||
|
const res = await agent.get("/shopping-list").query({ date: "not-a-date" });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(400);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a date shaped right but calendarially impossible with 400 VALIDATION_ERROR", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
|
||||||
|
const res = await agent.get("/shopping-list").query({ date: "2026-02-30" });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(400);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when the profile has no household", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
|
||||||
|
const res = await agent.get("/shopping-list").query({ date: today() });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body.items).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when the household has no planning covering that date", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
await agent.post("/house").send({ name: "Chez moi" });
|
||||||
|
|
||||||
|
const res = await agent.get("/shopping-list").query({ date: today() });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body.items).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums one recipe's ingredient across two planning slots, scaled by each slot's own portions", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||||
|
const houseId: number = houseRes.body.id;
|
||||||
|
const authorId: number = houseRes.body.adminId;
|
||||||
|
|
||||||
|
const tomatoId = await ingredientId("tomato");
|
||||||
|
const gramId = await unitId("gram");
|
||||||
|
|
||||||
|
// Written for 2 portions, 100g tomato — planned twice this week at
|
||||||
|
// 4 portions each, so the shopping list should show 100 × (4/2) × 2
|
||||||
|
// = 400g, not the raw 200g the recipe itself lists.
|
||||||
|
const recipe = await prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name: "Salade de tomates",
|
||||||
|
authorId,
|
||||||
|
portions: 2,
|
||||||
|
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const planning = await prisma.planning.create({
|
||||||
|
data: {
|
||||||
|
houseId,
|
||||||
|
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
|
||||||
|
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.planningItem.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
planningId: planning.id,
|
||||||
|
weekDay: "lundi",
|
||||||
|
meal: "dejeuner",
|
||||||
|
recipeId: recipe.id,
|
||||||
|
portions: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
planningId: planning.id,
|
||||||
|
weekDay: "mercredi",
|
||||||
|
meal: "diner",
|
||||||
|
recipeId: recipe.id,
|
||||||
|
portions: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await agent.get("/shopping-list").query({ date: today() });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body.items).to.have.length(1);
|
||||||
|
expect(res.body.items[0].ingredient.key).to.equal("tomato");
|
||||||
|
expect(res.body.items[0].unit.key).to.equal("gram");
|
||||||
|
expect(res.body.items[0].quantity).to.equal(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums the same ingredient across two different recipes sharing a unit", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||||
|
const houseId: number = houseRes.body.id;
|
||||||
|
const authorId: number = houseRes.body.adminId;
|
||||||
|
|
||||||
|
const onionId = await ingredientId("onion");
|
||||||
|
const gramId = await unitId("gram");
|
||||||
|
|
||||||
|
const recipeA = await prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name: "Soupe à l'oignon",
|
||||||
|
authorId,
|
||||||
|
portions: 4,
|
||||||
|
ingredients: { create: [{ ingredientId: onionId, quantity: 200, unitId: gramId }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const recipeB = await prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name: "Tarte à l'oignon",
|
||||||
|
authorId,
|
||||||
|
portions: 4,
|
||||||
|
ingredients: { create: [{ ingredientId: onionId, quantity: 150, unitId: gramId }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const planning = await prisma.planning.create({
|
||||||
|
data: {
|
||||||
|
houseId,
|
||||||
|
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
|
||||||
|
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.planningItem.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
planningId: planning.id,
|
||||||
|
weekDay: "lundi",
|
||||||
|
meal: "dejeuner",
|
||||||
|
recipeId: recipeA.id,
|
||||||
|
portions: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
planningId: planning.id,
|
||||||
|
weekDay: "mardi",
|
||||||
|
meal: "diner",
|
||||||
|
recipeId: recipeB.id,
|
||||||
|
portions: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await agent.get("/shopping-list").query({ date: today() });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body.items).to.have.length(1);
|
||||||
|
expect(res.body.items[0].ingredient.key).to.equal("onion");
|
||||||
|
expect(res.body.items[0].quantity).to.equal(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the same ingredient in two different units as two separate lines", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||||
|
const houseId: number = houseRes.body.id;
|
||||||
|
const authorId: number = houseRes.body.adminId;
|
||||||
|
|
||||||
|
const tomatoId = await ingredientId("tomato");
|
||||||
|
const gramId = await unitId("gram");
|
||||||
|
const kilogramId = await unitId("kilogram");
|
||||||
|
|
||||||
|
const recipeA = await prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name: "Recette A",
|
||||||
|
authorId,
|
||||||
|
portions: 2,
|
||||||
|
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const recipeB = await prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name: "Recette B",
|
||||||
|
authorId,
|
||||||
|
portions: 2,
|
||||||
|
ingredients: { create: [{ ingredientId: tomatoId, quantity: 1, unitId: kilogramId }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const planning = await prisma.planning.create({
|
||||||
|
data: {
|
||||||
|
houseId,
|
||||||
|
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
|
||||||
|
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.planningItem.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
planningId: planning.id,
|
||||||
|
weekDay: "lundi",
|
||||||
|
meal: "dejeuner",
|
||||||
|
recipeId: recipeA.id,
|
||||||
|
portions: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
planningId: planning.id,
|
||||||
|
weekDay: "mardi",
|
||||||
|
meal: "diner",
|
||||||
|
recipeId: recipeB.id,
|
||||||
|
portions: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await agent.get("/shopping-list").query({ date: today() });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body.items).to.have.length(2);
|
||||||
|
const units = res.body.items.map((item: { unit: { key: string } }) => item.unit.key).sort();
|
||||||
|
expect(units).to.deep.equal(["gram", "kilogram"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a different week's shopping list when asked for a date outside the current one", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||||
|
const houseId: number = houseRes.body.id;
|
||||||
|
const authorId: number = houseRes.body.adminId;
|
||||||
|
|
||||||
|
const tomatoId = await ingredientId("tomato");
|
||||||
|
const gramId = await unitId("gram");
|
||||||
|
const recipe = await prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name: "Curry de lentilles",
|
||||||
|
authorId,
|
||||||
|
portions: 2,
|
||||||
|
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 });
|
||||||
|
const planning = await prisma.planning.create({
|
||||||
|
data: {
|
||||||
|
houseId,
|
||||||
|
startDate: nextWeek.startOf("week").toJSDate(),
|
||||||
|
finishDate: nextWeek.endOf("week").startOf("day").toJSDate(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.planningItem.create({
|
||||||
|
data: {
|
||||||
|
planningId: planning.id,
|
||||||
|
weekDay: "mardi",
|
||||||
|
meal: "dejeuner",
|
||||||
|
recipeId: recipe.id,
|
||||||
|
portions: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextWeekRes = await agent.get("/shopping-list").query({ date: isoDate(nextWeek) });
|
||||||
|
expect(nextWeekRes.body.items).to.have.length(1);
|
||||||
|
|
||||||
|
const thisWeekRes = await agent.get("/shopping-list").query({ date: today() });
|
||||||
|
expect(thisWeekRes.body.items).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
|
@ -11,15 +12,40 @@ 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(
|
/**
|
||||||
overrides: Partial<{
|
* A real `StepDescription` resolves `onRequestSpan` into a fresh
|
||||||
previousTechStepId: number | null;
|
* `resolvedMetadataSpan` via an actual browser text selection — out of
|
||||||
onClose: () => void;
|
* scope for a component test of the popover alone (covered by the e2e
|
||||||
onSubmitted: (correction: unknown) => void;
|
* 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
|
||||||
cy.mount(
|
* `StepDescription` in the tree.
|
||||||
|
*/
|
||||||
|
function Harness({
|
||||||
|
previousTechStepId = null,
|
||||||
|
existingIngredients = [],
|
||||||
|
existingUtensils = [],
|
||||||
|
onClose = () => {},
|
||||||
|
onSubmitted = () => {},
|
||||||
|
}: Partial<{
|
||||||
|
previousTechStepId: number | null;
|
||||||
|
existingIngredients: unknown[];
|
||||||
|
existingUtensils: unknown[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmitted: (result: unknown) => void;
|
||||||
|
}>) {
|
||||||
|
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
|
||||||
|
nonce: number;
|
||||||
|
kind: "ingredient" | "utensil";
|
||||||
|
range: { start: number; end: number };
|
||||||
|
text: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
<div>
|
<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 }} />
|
||||||
|
|
@ -28,11 +54,25 @@ function mountPopover(
|
||||||
stepId={2}
|
stepId={2}
|
||||||
selectedText="Cuire"
|
selectedText="Cuire"
|
||||||
range={{ start: 0, end: 5 }}
|
range={{ start: 0, end: 5 }}
|
||||||
previousTechStepId={overrides.previousTechStepId ?? null}
|
previousTechStepId={previousTechStepId}
|
||||||
onClose={overrides.onClose ?? (() => {})}
|
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView props — precise typing isn't the point here.
|
||||||
onSubmitted={overrides.onSubmitted ?? (() => {})}
|
existingIngredients={existingIngredients as any}
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||||
|
existingUtensils={existingUtensils as any}
|
||||||
|
resolvedMetadataSpan={resolvedMetadataSpan}
|
||||||
|
onRequestSpan={(kind) =>
|
||||||
|
setResolvedMetadataSpan({
|
||||||
|
nonce: Date.now(),
|
||||||
|
kind,
|
||||||
|
range: { start: 20, end: 26 },
|
||||||
|
text: "Beurre",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onClose={onClose}
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||||
|
onSubmitted={onSubmitted as any}
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,32 +81,75 @@ 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 and every technique option once loaded", () => {
|
it("shows the selected text, the technique catalog (searchable) and the metadata sections all together", () => {
|
||||||
mountPopover();
|
cy.mount(<Harness />);
|
||||||
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");
|
||||||
cy.get(".tech-step-correction-popover__list button").should("have.length", 2);
|
// Merged editor (see TechStepCorrectionPopover's own doc comment) — no
|
||||||
|
// separate "pick, then metadata reveals itself" step, both render at
|
||||||
|
// once, and the technique catalog goes through the same searchable
|
||||||
|
// `CatalogSearchPicker` as the ingredient/utensil sub-flows (a plain
|
||||||
|
// unfiltered list of the real ~74-entry catalog isn't browsable).
|
||||||
|
cy.get(
|
||||||
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||||
|
).should("have.length", 2);
|
||||||
|
cy.contains("h4", "Ingrédients").should("be.visible");
|
||||||
|
cy.contains("h4", "Ustensiles").should("be.visible");
|
||||||
|
cy.contains("button", "Valider").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers a 'no technique here' option only when correcting an existing match", () => {
|
it("offers a 'no technique here' option, and marks the current pick, only when correcting an existing match", () => {
|
||||||
mountPopover({ previousTechStepId: null });
|
cy.mount(<Harness 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");
|
||||||
|
|
||||||
mountPopover({ previousTechStepId: cook.id });
|
cy.mount(<Harness 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("submits the selected technique and calls onSubmitted", () => {
|
it("picking a technique from the catalog selects it without submitting immediately", () => {
|
||||||
// Asserting on the resolved `@submitCorrection` interception below,
|
cy.mount(<Harness />);
|
||||||
// rather than inside this handler — a Chai assertion failing *inside*
|
cy.wait("@getTechSteps");
|
||||||
// a `cy.intercept` callback surfaces as an opaque "onResponse cannot be
|
|
||||||
// called twice" Cypress internal error instead of a normal assertion
|
cy.contains(
|
||||||
// failure, found while writing this exact test.
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||||
|
"Mijoter",
|
||||||
|
).click();
|
||||||
|
|
||||||
|
cy.contains(".tech-step-correction-popover__chosen-technique", "Mijoter");
|
||||||
|
cy.contains("button", "Valider").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Valider stays disabled until a technique is actually picked", () => {
|
||||||
|
cy.mount(<Harness />);
|
||||||
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
|
cy.contains("button", "Valider").should("be.disabled");
|
||||||
|
cy.contains(
|
||||||
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||||
|
"Mijoter",
|
||||||
|
).click();
|
||||||
|
cy.contains("button", "Valider").should("not.be.disabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits the selected technique (no metadata touched) with ingredients/utensils omitted from the request", () => {
|
||||||
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
||||||
statusCode: 201,
|
statusCode: 201,
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -79,10 +162,14 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
},
|
},
|
||||||
}).as("submitCorrection");
|
}).as("submitCorrection");
|
||||||
const onSubmitted = cy.stub().as("onSubmitted");
|
const onSubmitted = cy.stub().as("onSubmitted");
|
||||||
mountPopover({ onSubmitted });
|
cy.mount(<Harness onSubmitted={onSubmitted} />);
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
|
cy.contains(
|
||||||
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||||
|
"Mijoter",
|
||||||
|
).click();
|
||||||
|
cy.contains("button", "Valider").click();
|
||||||
|
|
||||||
cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
|
cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
|
||||||
start: 0,
|
start: 0,
|
||||||
|
|
@ -93,16 +180,85 @@ 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");
|
||||||
mountPopover({ onClose });
|
cy.mount(<Harness onClose={onClose} />);
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__list button", "Cuire").click();
|
cy.contains(
|
||||||
|
".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");
|
||||||
|
|
@ -111,7 +267,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");
|
||||||
mountPopover({ onClose });
|
cy.mount(<Harness onClose={onClose} />);
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
cy.get('[data-testid="outside-popover"]').click();
|
cy.get('[data-testid="outside-popover"]').click();
|
||||||
|
|
|
||||||
|
|
@ -147,11 +147,13 @@ describe("Page width — full-bleed pages vs. centered reading columns (#21 regr
|
||||||
// that cap was dropped so they fill the width like every other page.
|
// that cap was dropped so they fill the width like every other page.
|
||||||
cy.visit("/parametres/compte");
|
cy.visit("/parametres/compte");
|
||||||
assertFillsContentWidth(".settings-page");
|
assertFillsContentWidth(".settings-page");
|
||||||
});
|
|
||||||
|
|
||||||
it("centers the Liste de courses stub, with equal space on both sides", () => {
|
cy.intercept("GET", /\/shopping-list\?/, {
|
||||||
|
statusCode: 200,
|
||||||
|
body: { startDate: "2026-08-17", finishDate: "2026-08-23", items: [] },
|
||||||
|
});
|
||||||
cy.visit("/liste-de-courses");
|
cy.visit("/liste-de-courses");
|
||||||
assertCenteredColumn(".coming-soon-page", 640); // max-width: 40rem
|
assertFillsContentWidth(".shopping-list-page");
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Fills `.app-content`'s available (padding-excluded) width, within a couple px of scrollbar/rounding slack. */
|
/** Fills `.app-content`'s available (padding-excluded) width, within a couple px of scrollbar/rounding slack. */
|
||||||
|
|
@ -168,22 +170,6 @@ describe("Page width — full-bleed pages vs. centered reading columns (#21 regr
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Capped at `maxWidthPx` (not stretched full-bleed) and horizontally centered — equal left/right gap within `.app-content`. */
|
|
||||||
function assertCenteredColumn(selector: string, maxWidthPx: number) {
|
|
||||||
cy.get(".app-content").then(($content) => {
|
|
||||||
const contentRect = $content[0].getBoundingClientRect();
|
|
||||||
|
|
||||||
cy.get(selector).should(($page) => {
|
|
||||||
const pageRect = $page[0].getBoundingClientRect();
|
|
||||||
expect(pageRect.width).to.be.closeTo(maxWidthPx, 2);
|
|
||||||
|
|
||||||
const leftGap = pageRect.left - contentRect.left;
|
|
||||||
const rightGap = contentRect.right - pageRect.right;
|
|
||||||
expect(leftGap).to.be.closeTo(rightGap, 2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Responsive breakpoint — sidebar becomes a horizontal top bar under 640px", () => {
|
describe("Responsive breakpoint — sidebar becomes a horizontal top bar under 640px", () => {
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ describe("Sidebar navigation", () => {
|
||||||
|
|
||||||
// The Foyer/Compte/Préférences links — behind the sidebar's "Paramètres"
|
// The Foyer/Compte/Préférences links — behind the sidebar's "Paramètres"
|
||||||
// toggle, not the main nav tested here — are covered by sidebar.cy.ts.
|
// toggle, not the main nav tested here — are covered by sidebar.cy.ts.
|
||||||
it("highlights the current section and navigates between stub pages", () => {
|
it("highlights the current section and navigates between pages", () => {
|
||||||
cy.contains("nav a", "Planning").should("have.class", "active");
|
cy.contains("nav a", "Planning").should("have.class", "active");
|
||||||
|
|
||||||
cy.contains("nav a", "Recettes").click();
|
cy.contains("nav a", "Recettes").click();
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,16 @@ 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: [{ techStep: { id: 3, key: "simmer" }, start: 0, end: 5, source: "manual" }],
|
techSteps: [
|
||||||
|
{
|
||||||
|
techStep: { id: 3, key: "simmer" },
|
||||||
|
start: 0,
|
||||||
|
end: 5,
|
||||||
|
source: "manual",
|
||||||
|
ingredients: [],
|
||||||
|
utensils: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}).as("correction");
|
}).as("correction");
|
||||||
});
|
});
|
||||||
|
|
@ -101,8 +110,21 @@ 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(".tech-step-correction-popover__list button", label).click();
|
cy.contains(
|
||||||
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||||
|
label,
|
||||||
|
).click();
|
||||||
|
cy.contains(".tech-step-correction-popover__confirm-button", "Valider").click();
|
||||||
});
|
});
|
||||||
|
|
||||||
Then(
|
Then(
|
||||||
|
|
|
||||||
32
apps/web/cypress/e2e/shopping-list.feature
Normal file
32
apps/web/cypress/e2e/shopping-list.feature
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
Feature: Shopping list
|
||||||
|
As a signed-in user
|
||||||
|
I want to see every ingredient needed for this week's planned recipes, already summed
|
||||||
|
So that I know what to buy without recomputing it myself
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in as "Alice" "Martin"
|
||||||
|
And today is frozen at "2026-08-17T09:00:00.000Z"
|
||||||
|
|
||||||
|
Scenario: Nothing planned this week shows the empty message, not an error
|
||||||
|
Given the shopping list for "2026-08-17" is empty
|
||||||
|
When I visit "/liste-de-courses"
|
||||||
|
Then I should see "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning."
|
||||||
|
|
||||||
|
Scenario: Ingredients are grouped by aisle, in canonical order, each with its summed quantity
|
||||||
|
Given the shopping list for "2026-08-17" contains:
|
||||||
|
| ingredientKey | icon | category | quantity | unitKey |
|
||||||
|
| egg | EGG | dairyAndCheese | 6 | piece |
|
||||||
|
| tomato | VEGETABLE | freshProduce | 400 | gram |
|
||||||
|
When I visit "/liste-de-courses"
|
||||||
|
Then the shopping list group "Produits frais" should appear before "Crémerie & fromage"
|
||||||
|
And the shopping list should show "Tomate" at quantity "400 g"
|
||||||
|
And the shopping list should show "Oeuf" at quantity "6 unité"
|
||||||
|
|
||||||
|
Scenario: Navigating to another week fetches and shows that week's own list
|
||||||
|
Given the shopping list for "2026-08-17" is empty
|
||||||
|
And the shopping list for "2026-08-24" contains:
|
||||||
|
| ingredientKey | icon | category | quantity | unitKey |
|
||||||
|
| onion | VEGETABLE | freshProduce | 1 | kilogram |
|
||||||
|
When I visit "/liste-de-courses"
|
||||||
|
And I click the next week arrow
|
||||||
|
Then the shopping list should show "Oignon" at quantity "1 kg"
|
||||||
72
apps/web/cypress/e2e/shopping-list.ts
Normal file
72
apps/web/cypress/e2e/shopping-list.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { type DataTable, Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One data-table row → a fake `ShoppingListItemView` — same minimal-fixture
|
||||||
|
* convention as `recipe-form.ts`'s ingredient fixtures (only the fields
|
||||||
|
* `ShoppingListPage` actually reads at runtime: the ingredient's `key`/
|
||||||
|
* `icon`/`category` for `IngredientTypeIcon`/`CategoryIcon`/translation, the
|
||||||
|
* unit's `key`; `id` only needs to be unique per row for the React list
|
||||||
|
* key). `index` seeds both ids so two rows never collide.
|
||||||
|
*/
|
||||||
|
function buildShoppingListItem(
|
||||||
|
row: { ingredientKey: string; icon: string; category: string; quantity: string; unitKey: string },
|
||||||
|
index: number,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ingredient: {
|
||||||
|
id: index,
|
||||||
|
key: row.ingredientKey,
|
||||||
|
icon: row.icon,
|
||||||
|
category: row.category,
|
||||||
|
subcategory: row.category,
|
||||||
|
reproducible: false,
|
||||||
|
allergens: [],
|
||||||
|
diets: [],
|
||||||
|
},
|
||||||
|
quantity: Number(row.quantity),
|
||||||
|
unit: { id: index, key: row.unitKey, type: "MASS", toBaseFactor: 1 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Given("the shopping list for {string} is empty", (date: string) => {
|
||||||
|
cy.intercept("GET", `**/shopping-list?date=${date}`, {
|
||||||
|
statusCode: 200,
|
||||||
|
body: { startDate: date, finishDate: date, items: [] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("the shopping list for {string} contains:", (date: string, dataTable: DataTable) => {
|
||||||
|
const items = dataTable.hashes().map((row, i) => buildShoppingListItem(row, i + 1));
|
||||||
|
cy.intercept("GET", `**/shopping-list?date=${date}`, {
|
||||||
|
statusCode: 200,
|
||||||
|
body: { startDate: date, finishDate: date, items },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same class as PlanningPage's own week navigator (`WeekNavigator`, now
|
||||||
|
// shared between the two pages) — `planning-page.cy.ts` already exercises
|
||||||
|
// the prev/next arrows directly by class, same approach here.
|
||||||
|
When("I click the next week arrow", () => {
|
||||||
|
cy.get(".week-nav__arrow").last().click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the shopping list group {string} should appear before {string}",
|
||||||
|
(first: string, second: string) => {
|
||||||
|
cy.get(".shopping-list__group-title").then(($titles) => {
|
||||||
|
const texts = [...$titles].map((el) => el.textContent?.trim() ?? "");
|
||||||
|
const firstIndex = texts.findIndex((text) => text.includes(first));
|
||||||
|
const secondIndex = texts.findIndex((text) => text.includes(second));
|
||||||
|
expect(firstIndex, `"${first}" should be a rendered group`).to.be.greaterThan(-1);
|
||||||
|
expect(secondIndex, `"${second}" should be a rendered group`).to.be.greaterThan(-1);
|
||||||
|
expect(firstIndex).to.be.lessThan(secondIndex);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the shopping list should show {string} at quantity {string}",
|
||||||
|
(name: string, quantity: string) => {
|
||||||
|
cy.contains(".shopping-list__item", name).should("contain.text", quantity);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -17,6 +17,7 @@ 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,
|
||||||
|
|
@ -26,6 +27,7 @@ import {
|
||||||
type ThemePreference,
|
type ThemePreference,
|
||||||
type UnitView,
|
type UnitView,
|
||||||
type UpdateRecipeInput,
|
type UpdateRecipeInput,
|
||||||
|
type UtensilView,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -163,6 +165,18 @@ export class ApiClient {
|
||||||
return this._request(`/planning/items/${id}`, { method: "DELETE" });
|
return this._request(`/planning/items/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the current user's household's shopping list for the week
|
||||||
|
* covering `date` (`YYYY-MM-DD`, e.g. from `date-tools`'s
|
||||||
|
* `formatDateOnly`) — every ingredient across that week's planned
|
||||||
|
* recipes, summed. Unlike {@link getPlanningForWeek}, never resolves to
|
||||||
|
* `null`: no household or nothing planned that week both come back as a
|
||||||
|
* normal list with an empty `items` array.
|
||||||
|
*/
|
||||||
|
public getShoppingListForWeek(date: string): Promise<ShoppingListView> {
|
||||||
|
return this._request(`/shopping-list?date=${date}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
|
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
|
||||||
public getDiets(): Promise<DietView[]> {
|
public getDiets(): Promise<DietView[]> {
|
||||||
return this._request("/reference/diets");
|
return this._request("/reference/diets");
|
||||||
|
|
@ -188,6 +202,11 @@ 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");
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
// =============================================================================
|
|
||||||
// Styles for ComingSoonPage — shared by every stub section page.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
// Centered, not pinned to `.app-content`'s left edge — same reasoning as
|
|
||||||
// `.settings-page` (settings-pages.scss): on a wide desktop viewport a
|
|
||||||
// left-aligned `max-width` here just left a lopsided gap down the right
|
|
||||||
// side instead of framing the placeholder copy.
|
|
||||||
.coming-soon-page {
|
|
||||||
max-width: 40rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import "./ComingSoonPage.scss";
|
|
||||||
|
|
||||||
interface ComingSoonPageProps {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder rendered by a section that has a route/sidebar entry but no
|
|
||||||
* real feature behind it yet — today only `pages/shopping-list/ShoppingListPage.tsx`
|
|
||||||
* (`Recettes`/`Foyer & profil` both grew real backends since this was
|
|
||||||
* written, see `pages/recipes/`/`pages/settings/`). Kept as a shared,
|
|
||||||
* reusable component (`components/ui/`, not itself a routed page) rather
|
|
||||||
* than inlined into that one page, so a future stub section doesn't need to
|
|
||||||
* hand-roll the same markup — the page that needs it still gets its own
|
|
||||||
* file (and its own copy, via i18n), just wrapping this instead of
|
|
||||||
* rewriting it.
|
|
||||||
*/
|
|
||||||
export function ComingSoonPage({ title, description }: ComingSoonPageProps) {
|
|
||||||
return (
|
|
||||||
<div className="coming-soon-page">
|
|
||||||
<h1>{title}</h1>
|
|
||||||
<p>{description}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
174
apps/web/src/features/planning/WeekNavigator.tsx
Normal file
174
apps/web/src/features/planning/WeekNavigator.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import {
|
||||||
|
addWeeks,
|
||||||
|
buildCalendarMonth,
|
||||||
|
DateTime,
|
||||||
|
getWeekStart,
|
||||||
|
toDateOnly,
|
||||||
|
} from "@batch-cooking/date-tools";
|
||||||
|
import { WEEK_DAYS } from "@batch-cooking/shared";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import "./week-navigator.scss";
|
||||||
|
|
||||||
|
/** "17 au 23 août 2026" — collapses the month/year to just the end date when both ends of the week share it, spells it out on both ends otherwise (e.g. a week straddling two months). */
|
||||||
|
function formatWeekRange(weekStart: DateTime): string {
|
||||||
|
const weekEnd = weekStart.plus({ days: 6 });
|
||||||
|
const sameMonth = weekStart.hasSame(weekEnd, "month");
|
||||||
|
const startLabel = weekStart.toLocaleString(
|
||||||
|
sameMonth ? { day: "numeric" } : { day: "numeric", month: "long" },
|
||||||
|
{ locale: "fr" },
|
||||||
|
);
|
||||||
|
const endLabel = weekEnd.toLocaleString(
|
||||||
|
{ day: "numeric", month: "long", year: "numeric" },
|
||||||
|
{ locale: "fr" },
|
||||||
|
);
|
||||||
|
return `${startLabel} au ${endLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrows + clickable label opening {@link CalendarPopover} — week-selection
|
||||||
|
* UI shared by any page organized around "one week at a time" (originally
|
||||||
|
* `PlanningPage`'s own grid, now also `ShoppingListPage` — both just need a
|
||||||
|
* `weekStart` in/out, neither cares how the other renders its own content
|
||||||
|
* for that week). Copy comes from `common.weekNav.*`/`common.calendar.*`/
|
||||||
|
* `common.days.*` rather than `planning.*` — generic enough ("Semaine
|
||||||
|
* précédente", day names) to not read as planning-specific from a page that
|
||||||
|
* isn't the planning grid.
|
||||||
|
*/
|
||||||
|
export function WeekNavigator({
|
||||||
|
weekStart,
|
||||||
|
onChangeWeek,
|
||||||
|
}: {
|
||||||
|
weekStart: DateTime;
|
||||||
|
onChangeWeek: (weekStart: DateTime) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||||||
|
const isThisWeek = weekStart.hasSame(getWeekStart(DateTime.utc()), "day");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="week-nav">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="week-nav__arrow"
|
||||||
|
title={t("common.weekNav.prevWeek")}
|
||||||
|
onClick={() => onChangeWeek(addWeeks(weekStart, -1))}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="week-nav__label"
|
||||||
|
onClick={() => setIsCalendarOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
📅 {t("common.weekNav.label", { range: formatWeekRange(weekStart) })}
|
||||||
|
{isThisWeek && <span className="today-badge">{t("common.weekNav.thisWeek")}</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="week-nav__arrow"
|
||||||
|
title={t("common.weekNav.nextWeek")}
|
||||||
|
onClick={() => onChangeWeek(addWeeks(weekStart, 1))}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isCalendarOpen && (
|
||||||
|
<CalendarPopover
|
||||||
|
selectedWeekStart={weekStart}
|
||||||
|
onSelectDay={(day) => {
|
||||||
|
onChangeWeek(getWeekStart(day));
|
||||||
|
setIsCalendarOpen(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setIsCalendarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Month calendar letting the visitor jump to any week at once — selecting a day selects its whole (Monday-first) week. Closes itself on an outside click. */
|
||||||
|
function CalendarPopover({
|
||||||
|
selectedWeekStart,
|
||||||
|
onSelectDay,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
selectedWeekStart: DateTime;
|
||||||
|
onSelectDay: (day: DateTime) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Its own state: browsing to a different month to pick a week there
|
||||||
|
// shouldn't jump back every render — only re-anchors when the popover is
|
||||||
|
// first opened (`selectedWeekStart` at that point), not while it's open.
|
||||||
|
const [visibleMonth, setVisibleMonth] = useState(() => selectedWeekStart.startOf("month"));
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const today = toDateOnly(DateTime.utc());
|
||||||
|
const selectedWeekEnd = selectedWeekStart.plus({ days: 6 });
|
||||||
|
const weeks = buildCalendarMonth(visibleMonth);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="calendar-popover" ref={popoverRef}>
|
||||||
|
<div className="calendar-popover__header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t("common.calendar.prevMonth")}
|
||||||
|
onClick={() => setVisibleMonth((month) => month.minus({ months: 1 }))}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
{visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t("common.calendar.nextMonth")}
|
||||||
|
onClick={() => setVisibleMonth((month) => month.plus({ months: 1 }))}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="calendar-grid">
|
||||||
|
{WEEK_DAYS.map((weekDay) => (
|
||||||
|
<span key={weekDay} className="calendar-grid__weekday">
|
||||||
|
{t(`common.days.${weekDay}`).charAt(0)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{weeks.flat().map((day) => {
|
||||||
|
const classNames = ["calendar-grid__day"];
|
||||||
|
if (!day.hasSame(visibleMonth, "month")) classNames.push("calendar-grid__day--muted");
|
||||||
|
if (day >= selectedWeekStart && day <= selectedWeekEnd) {
|
||||||
|
classNames.push("calendar-grid__day--in-selected-week");
|
||||||
|
}
|
||||||
|
if (day.hasSame(today, "day")) classNames.push("calendar-grid__day--today");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day.toISO()}
|
||||||
|
type="button"
|
||||||
|
className={classNames.join(" ")}
|
||||||
|
onClick={() => onSelectDay(day)}
|
||||||
|
>
|
||||||
|
{day.day}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
apps/web/src/features/planning/week-navigator.scss
Normal file
143
apps/web/src/features/planning/week-navigator.scss
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
// =============================================================================
|
||||||
|
// Styles for WeekNavigator.tsx (arrows + label + calendar popover) —
|
||||||
|
// colocated next to the component since nothing else uses these classes.
|
||||||
|
// Extracted from planning-page.scss once ShoppingListPage started reusing
|
||||||
|
// the component — same design tokens, no light/dark duplication needed
|
||||||
|
// (every `var(--color-*)` below already resolves per-theme globally, see
|
||||||
|
// styles/_theme.scss).
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.week-nav {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
|
||||||
|
&__arrow {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: 0.45rem var(--space-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-badge {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Calendar popover -------------------------------------------------------
|
||||||
|
.calendar-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--space-xs));
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
width: 18rem;
|
||||||
|
padding: var(--space-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
text-transform: capitalize;
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
&__weekday {
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
padding-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__day {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--muted {
|
||||||
|
color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--in-selected-week {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--today {
|
||||||
|
box-shadow: inset 0 0 0 2px var(--color-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -729,40 +729,38 @@
|
||||||
margin: 0 0 var(--space-sm);
|
margin: 0 0 var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__list {
|
// Technique picker + Ingrédients/Ustensiles render together as one
|
||||||
display: flex;
|
// screen now (see `TechStepCorrectionPopover.tsx`'s own doc comment) —
|
||||||
flex-wrap: wrap;
|
// this section just needs its own small header row, the actual picker
|
||||||
gap: var(--space-xs);
|
// is `.catalog-search-picker` (below), reused as-is from the ingredient/
|
||||||
list-style: none;
|
// utensil sub-flows.
|
||||||
margin: 0 0 var(--space-sm);
|
&__technique-section {
|
||||||
padding: 0;
|
h4 {
|
||||||
|
margin: 0 0 var(--space-xs);
|
||||||
button {
|
|
||||||
padding: 0.3rem 0.6rem;
|
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text);
|
color: var(--color-text-muted);
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Nested (rather than a sibling `&__remove` block) so its border-color
|
&__technique-header {
|
||||||
// wins over the plain `button` rule above by class-count specificity,
|
display: flex;
|
||||||
// no `!important` needed.
|
align-items: baseline;
|
||||||
.tech-step-correction-popover__remove {
|
justify-content: space-between;
|
||||||
color: var(--color-error);
|
gap: var(--space-sm);
|
||||||
border-color: var(--color-error);
|
}
|
||||||
|
|
||||||
|
&__remove {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-error);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-error);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -775,6 +773,180 @@
|
||||||
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) -----------------------------
|
||||||
|
|
|
||||||
71
apps/web/src/features/recipes/steps/CatalogSearchPicker.tsx
Normal file
71
apps/web/src/features/recipes/steps/CatalogSearchPicker.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small search-and-pick list — a lighter alternative to `IngredientPicker.tsx`
|
||||||
|
* (category/subcategory grid + allergen/diet toggles) for a context that
|
||||||
|
* doesn't have room for that: `TechStepCorrectionPopover.tsx`'s ingredient/
|
||||||
|
* utensil/**technique** pickers, all embedded in a small popover rather than
|
||||||
|
* a full recipe form. Reused for all three — an ingredient, a utensil, and a
|
||||||
|
* technique are all "search a reference list by translated label, pick one"
|
||||||
|
* from this component's point of view, the only difference is which
|
||||||
|
* `items`/labels the caller passes in. The technique catalog in particular
|
||||||
|
* (~74 entries) is exactly the case a plain unfiltered list stops being
|
||||||
|
* readable at — the original motivation for adding search here at all.
|
||||||
|
*
|
||||||
|
* Deliberately just `{ id, label }` in, `id` out — no `IngredientView`/
|
||||||
|
* `UtensilView`/`TechStepView` dependency here, so this stays reusable for
|
||||||
|
* any future "search this small reference catalog" need without growing a
|
||||||
|
* new prop per catalog shape.
|
||||||
|
*/
|
||||||
|
export function CatalogSearchPicker({
|
||||||
|
items,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
placeholder,
|
||||||
|
emptyLabel,
|
||||||
|
}: {
|
||||||
|
items: { id: number; label: string }[];
|
||||||
|
/** The currently-picked item, if any — marked with a distinct modifier class so it stays visible at a glance while browsing/filtering a longer list (e.g. `TechStepCorrectionPopover`'s ~74-entry technique catalog), not just implied by whatever's selected elsewhere on screen. Omit for a picker with no notion of a "current" pick (the ingredient/utensil span sub-flows — each `onSelect` there just appends a brand-new mention, nothing to mark as already chosen). */
|
||||||
|
selectedId?: number;
|
||||||
|
onSelect: (id: number) => void;
|
||||||
|
placeholder: string;
|
||||||
|
emptyLabel: string;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const visible =
|
||||||
|
normalizedQuery.length === 0
|
||||||
|
? items
|
||||||
|
: items.filter((item) => item.label.toLowerCase().includes(normalizedQuery));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="catalog-search-picker">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="catalog-search-picker__input"
|
||||||
|
/>
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<p className="catalog-search-picker__empty">{emptyLabel}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="catalog-search-picker__list">
|
||||||
|
{visible.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
item.id === selectedId ? "catalog-search-picker__item--selected" : undefined
|
||||||
|
}
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -76,10 +76,45 @@ 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),
|
||||||
|
|
@ -91,6 +126,21 @@ 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
|
||||||
|
|
@ -154,12 +204,19 @@ 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
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -176,7 +233,11 @@ export function StepDescription({
|
||||||
range={activeCorrection.range}
|
range={activeCorrection.range}
|
||||||
selectedText={activeCorrection.selectedText}
|
selectedText={activeCorrection.selectedText}
|
||||||
previousTechStepId={activeCorrection.previousTechStepId}
|
previousTechStepId={activeCorrection.previousTechStepId}
|
||||||
onClose={() => setActiveCorrection(null)}
|
existingIngredients={activeStepTechStep?.ingredients ?? []}
|
||||||
|
existingUtensils={activeStepTechStep?.utensils ?? []}
|
||||||
|
resolvedMetadataSpan={resolvedMetadataSpan}
|
||||||
|
onRequestSpan={setPendingSpanRequest}
|
||||||
|
onClose={closeActiveCorrection}
|
||||||
onSubmitted={handleSubmitted}
|
onSubmitted={handleSubmitted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,48 @@
|
||||||
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 —
|
||||||
|
|
@ -21,14 +55,29 @@ import type { TextSelectionRange } from "./use-text-selection";
|
||||||
* `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.
|
* from the selected text itself. That placement matters beyond cosmetics
|
||||||
|
* here: it's *why* the "attach an ingredient/utensil" flow below can ask
|
||||||
|
* the viewer to select a second span of text without closing this popover
|
||||||
|
* first — the description stays fully visible and selectable the whole
|
||||||
|
* time, nothing overlays it.
|
||||||
*
|
*
|
||||||
* Submitting takes effect immediately — the API applies it to the step's
|
* **One merged editor, not a wizard**: picking a technique
|
||||||
* real `StepTechStep` sequence as it records the correction (a `"manual"`-
|
* (`CatalogSearchPicker`, searchable — the reference catalog is ~74
|
||||||
* tagged entry, see `StepTechStepCorrection`'s schema doc comment) and
|
* entries, an unfiltered flat list wasn't browsable) and editing its
|
||||||
* returns the fresh sequence, which `onSubmitted` hands back to
|
* Ingrédients/Ustensiles metadata render together on the same screen,
|
||||||
* `StepDescription` to render right away, styled differently from an
|
* always — there's no separate "pick, then a metadata step reveals
|
||||||
* `"auto"` match.
|
* itself" sequence to go through, and no dead end where metadata is
|
||||||
|
* technically attachable but not visible until some other action happens
|
||||||
|
* first. A single "Valider" submits everything at once; disabled until a
|
||||||
|
* technique is actually selected (there's nothing to attach metadata to
|
||||||
|
* otherwise). **Removing** a match (`submit(null)`) stays its own
|
||||||
|
* immediate action next to the picker — nothing to attach when removing.
|
||||||
|
*
|
||||||
|
* The two metadata sections are pre-seeded from `existingIngredients`/
|
||||||
|
* `existingUtensils` (whatever's already attached to this occurrence, auto-
|
||||||
|
* or manually-sourced — `[]` for a brand-new technique) and editable via
|
||||||
|
* add/remove — see `metadataTouched` below for why what's *displayed* here
|
||||||
|
* isn't automatically what gets *submitted*.
|
||||||
*/
|
*/
|
||||||
export function TechStepCorrectionPopover({
|
export function TechStepCorrectionPopover({
|
||||||
recipeId,
|
recipeId,
|
||||||
|
|
@ -36,6 +85,10 @@ export function TechStepCorrectionPopover({
|
||||||
selectedText,
|
selectedText,
|
||||||
range,
|
range,
|
||||||
previousTechStepId,
|
previousTechStepId,
|
||||||
|
existingIngredients,
|
||||||
|
existingUtensils,
|
||||||
|
resolvedMetadataSpan,
|
||||||
|
onRequestSpan,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmitted,
|
onSubmitted,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -46,12 +99,74 @@ 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);
|
||||||
|
|
||||||
|
|
@ -70,6 +185,46 @@ 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)) {
|
||||||
|
|
@ -80,7 +235,7 @@ export function TechStepCorrectionPopover({
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
async function submit(correctedTechStepId: number | null) {
|
async function removeMatch() {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -88,7 +243,7 @@ export function TechStepCorrectionPopover({
|
||||||
start: range.start,
|
start: range.start,
|
||||||
end: range.end,
|
end: range.end,
|
||||||
previousTechStepId,
|
previousTechStepId,
|
||||||
correctedTechStepId,
|
correctedTechStepId: null,
|
||||||
});
|
});
|
||||||
onSubmitted(result);
|
onSubmitted(result);
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -99,40 +254,260 @@ 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>
|
||||||
) : (
|
) : (
|
||||||
<ul className="tech-step-correction-popover__list">
|
<div className="tech-step-correction-popover__confirm">
|
||||||
{previousTechStepId !== null && (
|
<section className="tech-step-correction-popover__technique-section">
|
||||||
<li>
|
<div className="tech-step-correction-popover__technique-header">
|
||||||
<button
|
<h4>{t("recipes.techStepCorrection.techniqueSection")}</h4>
|
||||||
type="button"
|
{previousTechStepId !== null && (
|
||||||
disabled={isSubmitting}
|
<button
|
||||||
onClick={() => submit(null)}
|
type="button"
|
||||||
className="tech-step-correction-popover__remove"
|
disabled={isSubmitting}
|
||||||
>
|
onClick={removeMatch}
|
||||||
{t("recipes.techStepCorrection.removeMatch")}
|
className="tech-step-correction-popover__remove"
|
||||||
</button>
|
>
|
||||||
</li>
|
{t("recipes.techStepCorrection.removeMatch")}
|
||||||
)}
|
</button>
|
||||||
{techSteps.map((techStep) => (
|
)}
|
||||||
<li key={techStep.id}>
|
</div>
|
||||||
<button
|
<p className="tech-step-correction-popover__chosen-technique">
|
||||||
type="button"
|
{selectedTechStepId !== null
|
||||||
disabled={isSubmitting || techStep.id === previousTechStepId}
|
? t("recipes.techStepCorrection.currentTechnique", {
|
||||||
onClick={() => submit(techStep.id)}
|
technique: t(
|
||||||
>
|
`catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`,
|
||||||
{t(`catalog.techSteps.${techStep.key}`)}
|
),
|
||||||
</button>
|
})
|
||||||
</li>
|
: t("recipes.techStepCorrection.noTechniqueSelected")}
|
||||||
))}
|
</p>
|
||||||
</ul>
|
<CatalogSearchPicker
|
||||||
|
items={techSteps.map((techStep) => ({
|
||||||
|
id: techStep.id,
|
||||||
|
label: t(`catalog.techSteps.${techStep.key}`),
|
||||||
|
}))}
|
||||||
|
selectedId={selectedTechStepId ?? undefined}
|
||||||
|
onSelect={setSelectedTechStepId}
|
||||||
|
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
|
||||||
|
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="tech-step-correction-popover__metadata-section">
|
||||||
|
<h4>{t("recipes.techStepCorrection.ingredientsSection")}</h4>
|
||||||
|
<ul className="tech-step-correction-popover__chips">
|
||||||
|
{pendingIngredients.map((ingredient, index) => {
|
||||||
|
const view = ingredientById.get(ingredient.ingredientId);
|
||||||
|
const unit =
|
||||||
|
ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
|
||||||
|
const label = view ? t(`catalog.ingredients.${view.key}`) : "…";
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
|
||||||
|
<li key={index} className="tech-step-correction-popover__chip">
|
||||||
|
{ingredient.quantity !== null ? `${ingredient.quantity} ` : ""}
|
||||||
|
{unit ? `${t(`catalog.units.${unit.key}`)} ` : ""}
|
||||||
|
{label}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeIngredient(index)}
|
||||||
|
title={t("recipes.techStepCorrection.removeIngredient")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<button type="button" onClick={() => requestSpan("ingredient")} disabled={isSubmitting}>
|
||||||
|
{t("recipes.techStepCorrection.addIngredient")}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="tech-step-correction-popover__metadata-section">
|
||||||
|
<h4>{t("recipes.techStepCorrection.utensilsSection")}</h4>
|
||||||
|
<ul className="tech-step-correction-popover__chips">
|
||||||
|
{pendingUtensils.map((utensil, index) => {
|
||||||
|
const view = utensilById.get(utensil.utensilId);
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient chip list above.
|
||||||
|
<li key={index} className="tech-step-correction-popover__chip">
|
||||||
|
{view ? t(`catalog.utensils.${view.key}`) : "…"}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeUtensil(index)}
|
||||||
|
title={t("recipes.techStepCorrection.removeUtensil")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<button type="button" onClick={() => requestSpan("utensil")} disabled={isSubmitting}>
|
||||||
|
{t("recipes.techStepCorrection.addUtensil")}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__confirm-button"
|
||||||
|
onClick={confirm}
|
||||||
|
disabled={isSubmitting || selectedTechStepId === null}
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.confirm")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="field-error">{error}</p>}
|
{error && <p className="field-error">{error}</p>}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,26 @@
|
||||||
"common": {
|
"common": {
|
||||||
"saving": "Enregistrement…",
|
"saving": "Enregistrement…",
|
||||||
"saved": "Enregistré ✓",
|
"saved": "Enregistré ✓",
|
||||||
"loadError": "Impossible de charger le planning, réessayez plus tard"
|
"loadError": "Impossible de charger le planning, réessayez plus tard",
|
||||||
|
"weekNav": {
|
||||||
|
"thisWeek": "Cette semaine",
|
||||||
|
"prevWeek": "Semaine précédente",
|
||||||
|
"nextWeek": "Semaine suivante",
|
||||||
|
"label": "Semaine du {{range}}"
|
||||||
|
},
|
||||||
|
"calendar": {
|
||||||
|
"prevMonth": "Mois précédent",
|
||||||
|
"nextMonth": "Mois suivant"
|
||||||
|
},
|
||||||
|
"days": {
|
||||||
|
"lundi": "Lundi",
|
||||||
|
"mardi": "Mardi",
|
||||||
|
"mercredi": "Mercredi",
|
||||||
|
"jeudi": "Jeudi",
|
||||||
|
"vendredi": "Vendredi",
|
||||||
|
"samedi": "Samedi",
|
||||||
|
"dimanche": "Dimanche"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"VALIDATION_ERROR": "Erreur de validation",
|
"VALIDATION_ERROR": "Erreur de validation",
|
||||||
|
|
@ -25,6 +44,7 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -102,25 +122,6 @@
|
||||||
"planning": {
|
"planning": {
|
||||||
"title": "Planning de la semaine",
|
"title": "Planning de la semaine",
|
||||||
"loading": "Chargement du planning…",
|
"loading": "Chargement du planning…",
|
||||||
"weekNav": {
|
|
||||||
"thisWeek": "Cette semaine",
|
|
||||||
"prevWeek": "Semaine précédente",
|
|
||||||
"nextWeek": "Semaine suivante",
|
|
||||||
"label": "Semaine du {{range}}"
|
|
||||||
},
|
|
||||||
"calendar": {
|
|
||||||
"prevMonth": "Mois précédent",
|
|
||||||
"nextMonth": "Mois suivant"
|
|
||||||
},
|
|
||||||
"days": {
|
|
||||||
"lundi": "Lundi",
|
|
||||||
"mardi": "Mardi",
|
|
||||||
"mercredi": "Mercredi",
|
|
||||||
"jeudi": "Jeudi",
|
|
||||||
"vendredi": "Vendredi",
|
|
||||||
"samedi": "Samedi",
|
|
||||||
"dimanche": "Dimanche"
|
|
||||||
},
|
|
||||||
"meals": {
|
"meals": {
|
||||||
"petit-dejeuner": "Petit-déjeuner",
|
"petit-dejeuner": "Petit-déjeuner",
|
||||||
"collation": "Collation",
|
"collation": "Collation",
|
||||||
|
|
@ -168,7 +169,24 @@
|
||||||
"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",
|
||||||
|
|
@ -289,7 +307,8 @@
|
||||||
},
|
},
|
||||||
"shoppingList": {
|
"shoppingList": {
|
||||||
"title": "Liste de courses",
|
"title": "Liste de courses",
|
||||||
"comingSoon": "Cette section arrive bientôt."
|
"loading": "Chargement de la liste de courses…",
|
||||||
|
"empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning."
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"title": "Compte",
|
"title": "Compte",
|
||||||
|
|
@ -424,7 +443,87 @@
|
||||||
"preheat": "Préchauffer",
|
"preheat": "Préchauffer",
|
||||||
"bake": "Cuire au four",
|
"bake": "Cuire au four",
|
||||||
"plate": "Dresser",
|
"plate": "Dresser",
|
||||||
"coat": "Napper"
|
"coat": "Napper",
|
||||||
|
"baste": "Arroser",
|
||||||
|
"appertize": "Appertiser",
|
||||||
|
"whiskPale": "Blanchir (jaunes d'œufs)",
|
||||||
|
"goldenBrown": "Blondir",
|
||||||
|
"braise": "Braiser",
|
||||||
|
"truss": "Brider",
|
||||||
|
"caramelize": "Caraméliser",
|
||||||
|
"score": "Cerner",
|
||||||
|
"lineMold": "Chemiser",
|
||||||
|
"clarify": "Clarifier",
|
||||||
|
"compote": "Compoter",
|
||||||
|
"concasse": "Concasser",
|
||||||
|
"confit": "Confire",
|
||||||
|
"julienne": "Couper en julienne",
|
||||||
|
"brunoise": "Couper en brunoise",
|
||||||
|
"mirepoix": "Couper en mirepoix",
|
||||||
|
"paysanne": "Couper en paysanne",
|
||||||
|
"blindBake": "Cuire à blanc",
|
||||||
|
"bainMarie": "Cuire au bain-marie",
|
||||||
|
"smother": "Cuire à l'étouffée",
|
||||||
|
"decant": "Décanter",
|
||||||
|
"dilute": "Délayer",
|
||||||
|
"punchDown": "Dégazer",
|
||||||
|
"disgorge": "Dégorger",
|
||||||
|
"loosen": "Détendre",
|
||||||
|
"shellEgg": "Écaler",
|
||||||
|
"scald": "Échauder",
|
||||||
|
"pod": "Écosser",
|
||||||
|
"emulsify": "Émulsionner",
|
||||||
|
"hollowOut": "Évider",
|
||||||
|
"shock": "Frapper",
|
||||||
|
"setGel": "Gélifier",
|
||||||
|
"glaze": "Glacer",
|
||||||
|
"thicken": "Lier",
|
||||||
|
"filet": "Lever les filets",
|
||||||
|
"proof": "Laisser pousser",
|
||||||
|
"peelBlanch": "Monder",
|
||||||
|
"whipUp": "Monter",
|
||||||
|
"moisten": "Mouiller",
|
||||||
|
"pasteurize": "Pasteuriser",
|
||||||
|
"poach": "Pocher",
|
||||||
|
"reduce": "Réduire",
|
||||||
|
"rubIn": "Sabler",
|
||||||
|
"dustWithFlour": "Singer",
|
||||||
|
"sweat": "Suer",
|
||||||
|
"sift": "Tamiser",
|
||||||
|
"toast": "Torréfier",
|
||||||
|
"zest": "Zester"
|
||||||
|
},
|
||||||
|
"utensils": {
|
||||||
|
"pan": "Poêle",
|
||||||
|
"saucepan": "Casserole",
|
||||||
|
"pot": "Marmite",
|
||||||
|
"knife": "Couteau",
|
||||||
|
"whisk": "Fouet",
|
||||||
|
"bowl": "Saladier",
|
||||||
|
"bakingSheet": "Plaque de cuisson",
|
||||||
|
"mold": "Moule",
|
||||||
|
"colander": "Passoire",
|
||||||
|
"cuttingBoard": "Planche à découper",
|
||||||
|
"oven": "Four",
|
||||||
|
"blender": "Blender",
|
||||||
|
"mixer": "Batteur",
|
||||||
|
"spatula": "Spatule",
|
||||||
|
"ladle": "Louche",
|
||||||
|
"grater": "Râpe",
|
||||||
|
"rollingPin": "Rouleau à pâtisserie",
|
||||||
|
"lid": "Couvercle",
|
||||||
|
"tongs": "Pince de cuisine",
|
||||||
|
"peeler": "Économe",
|
||||||
|
"sieve": "Tamis",
|
||||||
|
"foodProcessor": "Robot ménager",
|
||||||
|
"steamerBasket": "Panier vapeur",
|
||||||
|
"skewer": "Brochette",
|
||||||
|
"pastryBrush": "Pinceau de cuisine",
|
||||||
|
"ramekin": "Ramequin",
|
||||||
|
"dish": "Plat",
|
||||||
|
"wok": "Wok",
|
||||||
|
"thermometer": "Thermomètre",
|
||||||
|
"mandoline": "Mandoline"
|
||||||
},
|
},
|
||||||
"allergens": {
|
"allergens": {
|
||||||
"gluten": "Gluten",
|
"gluten": "Gluten",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
import {
|
import { DateTime, formatDateOnly, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
|
||||||
addWeeks,
|
|
||||||
buildCalendarMonth,
|
|
||||||
DateTime,
|
|
||||||
formatDateOnly,
|
|
||||||
getWeekStart,
|
|
||||||
toDateOnly,
|
|
||||||
} from "@batch-cooking/date-tools";
|
|
||||||
import {
|
import {
|
||||||
MEALS,
|
MEALS,
|
||||||
type Meal,
|
type Meal,
|
||||||
|
|
@ -13,10 +6,11 @@ import {
|
||||||
type PlanningView,
|
type PlanningView,
|
||||||
WEEK_DAYS,
|
WEEK_DAYS,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { apiClient } from "../../api/client";
|
import { apiClient } from "../../api/client";
|
||||||
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
|
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
|
||||||
|
import { WeekNavigator } from "../../features/planning/WeekNavigator";
|
||||||
import "./planning-page.scss";
|
import "./planning-page.scss";
|
||||||
|
|
||||||
/** Load state for the `GET /planning` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */
|
/** Load state for the `GET /planning` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */
|
||||||
|
|
@ -49,7 +43,7 @@ export function PlanningPage() {
|
||||||
// closed. Mounting the dialog only while this is set (rather than an
|
// closed. Mounting the dialog only while this is set (rather than an
|
||||||
// always-mounted `isOpen` toggle) resets its internal filter/search
|
// always-mounted `isOpen` toggle) resets its internal filter/search
|
||||||
// state for free on every open, same convention as `WeekNavigator`'s own
|
// state for free on every open, same convention as `WeekNavigator`'s own
|
||||||
// `CalendarPopover` below.
|
// `CalendarPopover` (features/planning/WeekNavigator.tsx).
|
||||||
const [openSlot, setOpenSlot] = useState<PlanningSlot | null>(null);
|
const [openSlot, setOpenSlot] = useState<PlanningSlot | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -144,160 +138,6 @@ export function PlanningPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "17 au 23 août 2026" — collapses the month/year to just the end date when both ends of the week share it, spells it out on both ends otherwise (e.g. a week straddling two months). */
|
|
||||||
function formatWeekRange(weekStart: DateTime): string {
|
|
||||||
const weekEnd = weekStart.plus({ days: 6 });
|
|
||||||
const sameMonth = weekStart.hasSame(weekEnd, "month");
|
|
||||||
const startLabel = weekStart.toLocaleString(
|
|
||||||
sameMonth ? { day: "numeric" } : { day: "numeric", month: "long" },
|
|
||||||
{ locale: "fr" },
|
|
||||||
);
|
|
||||||
const endLabel = weekEnd.toLocaleString(
|
|
||||||
{ day: "numeric", month: "long", year: "numeric" },
|
|
||||||
{ locale: "fr" },
|
|
||||||
);
|
|
||||||
return `${startLabel} au ${endLabel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Arrows + clickable label opening {@link CalendarPopover} — the week-selection UI at the top of the page. */
|
|
||||||
function WeekNavigator({
|
|
||||||
weekStart,
|
|
||||||
onChangeWeek,
|
|
||||||
}: {
|
|
||||||
weekStart: DateTime;
|
|
||||||
onChangeWeek: (weekStart: DateTime) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
|
||||||
const isThisWeek = weekStart.hasSame(getWeekStart(DateTime.utc()), "day");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="week-nav">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="week-nav__arrow"
|
|
||||||
title={t("planning.weekNav.prevWeek")}
|
|
||||||
onClick={() => onChangeWeek(addWeeks(weekStart, -1))}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="week-nav__label"
|
|
||||||
onClick={() => setIsCalendarOpen((open) => !open)}
|
|
||||||
>
|
|
||||||
📅 {t("planning.weekNav.label", { range: formatWeekRange(weekStart) })}
|
|
||||||
{isThisWeek && <span className="today-badge">{t("planning.weekNav.thisWeek")}</span>}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="week-nav__arrow"
|
|
||||||
title={t("planning.weekNav.nextWeek")}
|
|
||||||
onClick={() => onChangeWeek(addWeeks(weekStart, 1))}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isCalendarOpen && (
|
|
||||||
<CalendarPopover
|
|
||||||
selectedWeekStart={weekStart}
|
|
||||||
onSelectDay={(day) => {
|
|
||||||
onChangeWeek(getWeekStart(day));
|
|
||||||
setIsCalendarOpen(false);
|
|
||||||
}}
|
|
||||||
onClose={() => setIsCalendarOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Month calendar letting the visitor jump to any week at once — selecting a day selects its whole (Monday-first) week. Closes itself on an outside click. */
|
|
||||||
function CalendarPopover({
|
|
||||||
selectedWeekStart,
|
|
||||||
onSelectDay,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
selectedWeekStart: DateTime;
|
|
||||||
onSelectDay: (day: DateTime) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
// Its own state: browsing to a different month to pick a week there
|
|
||||||
// shouldn't jump back every render — only re-anchors when the popover is
|
|
||||||
// first opened (`selectedWeekStart` at that point), not while it's open.
|
|
||||||
const [visibleMonth, setVisibleMonth] = useState(() => selectedWeekStart.startOf("month"));
|
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const today = toDateOnly(DateTime.utc());
|
|
||||||
const selectedWeekEnd = selectedWeekStart.plus({ days: 6 });
|
|
||||||
const weeks = buildCalendarMonth(visibleMonth);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="calendar-popover" ref={popoverRef}>
|
|
||||||
<div className="calendar-popover__header">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={t("planning.calendar.prevMonth")}
|
|
||||||
onClick={() => setVisibleMonth((month) => month.minus({ months: 1 }))}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<span>
|
|
||||||
{visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={t("planning.calendar.nextMonth")}
|
|
||||||
onClick={() => setVisibleMonth((month) => month.plus({ months: 1 }))}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="calendar-grid">
|
|
||||||
{WEEK_DAYS.map((weekDay) => (
|
|
||||||
<span key={weekDay} className="calendar-grid__weekday">
|
|
||||||
{t(`planning.days.${weekDay}`).charAt(0)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{weeks.flat().map((day) => {
|
|
||||||
const classNames = ["calendar-grid__day"];
|
|
||||||
if (!day.hasSame(visibleMonth, "month")) classNames.push("calendar-grid__day--muted");
|
|
||||||
if (day >= selectedWeekStart && day <= selectedWeekEnd) {
|
|
||||||
classNames.push("calendar-grid__day--in-selected-week");
|
|
||||||
}
|
|
||||||
if (day.hasSame(today, "day")) classNames.push("calendar-grid__day--today");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={day.toISO()}
|
|
||||||
type="button"
|
|
||||||
className={classNames.join(" ")}
|
|
||||||
onClick={() => onSelectDay(day)}
|
|
||||||
>
|
|
||||||
{day.day}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The week grid itself — 7 day columns × 5 meal rows. */
|
/** The week grid itself — 7 day columns × 5 meal rows. */
|
||||||
function PlanningGrid({
|
function PlanningGrid({
|
||||||
weekStart,
|
weekStart,
|
||||||
|
|
@ -323,7 +163,7 @@ function PlanningGrid({
|
||||||
<th />
|
<th />
|
||||||
{days.map(({ weekDay, date }) => (
|
{days.map(({ weekDay, date }) => (
|
||||||
<th key={weekDay} className={date.hasSame(today, "day") ? "today" : undefined}>
|
<th key={weekDay} className={date.hasSame(today, "day") ? "today" : undefined}>
|
||||||
<span className="day-name">{t(`planning.days.${weekDay}`)}</span>
|
<span className="day-name">{t(`common.days.${weekDay}`)}</span>
|
||||||
<span className="day-date">{date.day}</span>
|
<span className="day-date">{date.day}</span>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -38,143 +38,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Week navigator (arrows + clickable label opening the calendar) -------
|
|
||||||
.week-nav {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
|
|
||||||
&__arrow {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
padding: 0.45rem var(--space-md);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-badge {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-primary);
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Calendar popover -------------------------------------------------------
|
|
||||||
.calendar-popover {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + var(--space-xs));
|
|
||||||
right: 0;
|
|
||||||
z-index: 10;
|
|
||||||
width: 18rem;
|
|
||||||
padding: var(--space-md);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
|
|
||||||
&__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
text-transform: capitalize;
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 1.6rem;
|
|
||||||
height: 1.6rem;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, 1fr);
|
|
||||||
gap: 2px;
|
|
||||||
|
|
||||||
&__weekday {
|
|
||||||
text-align: center;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
padding-bottom: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__day {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-text);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
font: inherit;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--muted {
|
|
||||||
color: var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--in-selected-week {
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--today {
|
|
||||||
box-shadow: inset 0 0 0 2px var(--color-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- The grid itself --------------------------------------------------------
|
// --- The grid itself --------------------------------------------------------
|
||||||
|
// (Week navigator + calendar popover styles now live in
|
||||||
|
// features/planning/week-navigator.scss, imported by WeekNavigator.tsx
|
||||||
|
// directly — extracted once ShoppingListPage started reusing that
|
||||||
|
// component too.)
|
||||||
.planning-grid-wrapper {
|
.planning-grid-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,114 @@
|
||||||
|
import { DateTime, formatDateOnly, getWeekStart } from "@batch-cooking/date-tools";
|
||||||
|
import type { ShoppingListItemView, ShoppingListView } from "@batch-cooking/shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ComingSoonPage } from "../../components/ui/ComingSoonPage";
|
import { apiClient } from "../../api/client";
|
||||||
|
import { WeekNavigator } from "../../features/planning/WeekNavigator";
|
||||||
|
import {
|
||||||
|
CategoryIcon,
|
||||||
|
IngredientTypeIcon,
|
||||||
|
} from "../../features/recipes/ingredients/ingredient-icons";
|
||||||
|
import { formatShoppingListQuantity, groupShoppingListItems } from "./shopping-list";
|
||||||
|
import "./shopping-list-page.scss";
|
||||||
|
|
||||||
/** Shopping list section — routed at `/liste-de-courses`. No backend yet, stub for now. */
|
/** Load state for the `GET /shopping-list` call — same discriminated-union shape as `PlanningPage`'s own `PlanningState`. */
|
||||||
|
type ShoppingListState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "loaded"; list: ShoppingListView }
|
||||||
|
| { status: "error" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shopping list section — routed at `/liste-de-courses`. Every ingredient
|
||||||
|
* line of every recipe planned for a selectable week, aggregated server-side
|
||||||
|
* (`GET /shopping-list`, see the API's `shopping-list.service.ts`) into one
|
||||||
|
* quantity per (ingredient, unit) pair, grouped by supermarket aisle for
|
||||||
|
* display. Deliberately simple by design — a read-only list, no
|
||||||
|
* checkboxes/crossing-off state: the source of truth for what's needed is
|
||||||
|
* the planning itself, not a separate to-do list this page would have to
|
||||||
|
* keep in sync with it.
|
||||||
|
*/
|
||||||
export function ShoppingListPage() {
|
export function ShoppingListPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
|
||||||
|
const [state, setState] = useState<ShoppingListState>({ status: "loading" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setState({ status: "loading" });
|
||||||
|
|
||||||
|
apiClient
|
||||||
|
.getShoppingListForWeek(formatDateOnly(weekStart))
|
||||||
|
.then((list) => {
|
||||||
|
if (!cancelled) setState({ status: "loaded", list });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setState({ status: "error" });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [weekStart]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComingSoonPage title={t("shoppingList.title")} description={t("shoppingList.comingSoon")} />
|
<div className="shopping-list-page">
|
||||||
|
<div className="shopping-list-page__header">
|
||||||
|
<h1>{t("shoppingList.title")}</h1>
|
||||||
|
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.status === "loading" && (
|
||||||
|
<p className="shopping-list-page__status">{t("shoppingList.loading")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "error" && (
|
||||||
|
<p className="shopping-list-page__status shopping-list-page__status--error">
|
||||||
|
{t("common.loadError")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "loaded" && <ShoppingListItems items={state.list.items} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The list itself, grouped by aisle (see `groupShoppingListItems`) — or the empty-week message if nothing's planned. */
|
||||||
|
function ShoppingListItems({ items }: { items: ShoppingListItemView[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <p className="shopping-list-page__status">{t("shoppingList.empty")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = groupShoppingListItems(items, (item) =>
|
||||||
|
t(`catalog.ingredients.${item.ingredient.key}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shopping-list">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<section key={group.category} className="shopping-list__group">
|
||||||
|
<h2 className="shopping-list__group-title">
|
||||||
|
<CategoryIcon category={group.category} />
|
||||||
|
{t(`recipes.form.category.${group.category}`)}
|
||||||
|
</h2>
|
||||||
|
<ul className="shopping-list__items">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<li key={`${item.ingredient.id}-${item.unit.id}`} className="shopping-list__item">
|
||||||
|
<span className="shopping-list__item-icon" aria-hidden="true">
|
||||||
|
<IngredientTypeIcon icon={item.ingredient.icon} />
|
||||||
|
</span>
|
||||||
|
<span className="shopping-list__item-name">
|
||||||
|
{t(`catalog.ingredients.${item.ingredient.key}`)}
|
||||||
|
</span>
|
||||||
|
<span className="shopping-list__item-quantity">
|
||||||
|
{formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
102
apps/web/src/pages/shopping-list/shopping-list-page.scss
Normal file
102
apps/web/src/pages/shopping-list/shopping-list-page.scss
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
// =============================================================================
|
||||||
|
// Styles specific to ShoppingListPage — colocated next to
|
||||||
|
// ShoppingListPage.tsx since nothing else uses these classes. Same page
|
||||||
|
// shell/status conventions as planning-page.scss (`.planning-page__header`/
|
||||||
|
// `__status`), a simple grouped list rather than a grid below it.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.shopping-list-page {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status--error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The list itself, grouped by aisle --------------------------------------
|
||||||
|
.shopping-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-list__group-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin: 0 0 var(--space-sm);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-list__items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-list__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-quantity {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
apps/web/src/pages/shopping-list/shopping-list.ts
Normal file
64
apps/web/src/pages/shopping-list/shopping-list.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import {
|
||||||
|
INGREDIENT_CATEGORIES,
|
||||||
|
type IngredientCategory,
|
||||||
|
type ShoppingListItemView,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
|
||||||
|
/** One aisle's worth of shopping list lines — see {@link groupShoppingListItems}. */
|
||||||
|
export interface ShoppingListGroup {
|
||||||
|
category: IngredientCategory;
|
||||||
|
items: ShoppingListItemView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups `items` by their ingredient's supermarket-aisle category (the same
|
||||||
|
* `IngredientCategory` the recipe form's `IngredientPicker` already browses
|
||||||
|
* by, see `ingredient-icons.tsx`'s `CategoryIcon`), in the app's canonical
|
||||||
|
* `INGREDIENT_CATEGORIES` order — a shopping list read aisle-by-aisle is far
|
||||||
|
* more useful in-store than one flat list. Within a group, lines are sorted
|
||||||
|
* by `ingredientLabel` — the caller's *already-translated* display name for
|
||||||
|
* that line, not the untranslated English `key` — so alphabetical order
|
||||||
|
* reads correctly in French; kept as a parameter (rather than calling
|
||||||
|
* `useTranslation` in here) so this stays a pure function the component can
|
||||||
|
* unit test without mounting i18next, same "logic extracted from the .tsx"
|
||||||
|
* split as every other feature in this codebase.
|
||||||
|
*/
|
||||||
|
export function groupShoppingListItems(
|
||||||
|
items: ShoppingListItemView[],
|
||||||
|
ingredientLabel: (item: ShoppingListItemView) => string,
|
||||||
|
): ShoppingListGroup[] {
|
||||||
|
const byCategory = new Map<IngredientCategory, ShoppingListItemView[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const category = item.ingredient.category;
|
||||||
|
const group = byCategory.get(category);
|
||||||
|
if (group) {
|
||||||
|
group.push(item);
|
||||||
|
} else {
|
||||||
|
byCategory.set(category, [item]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: ShoppingListGroup[] = [];
|
||||||
|
for (const category of INGREDIENT_CATEGORIES) {
|
||||||
|
const groupItems = byCategory.get(category);
|
||||||
|
if (!groupItems) continue;
|
||||||
|
groups.push({
|
||||||
|
category,
|
||||||
|
items: [...groupItems].sort((a, b) =>
|
||||||
|
ingredientLabel(a).localeCompare(ingredientLabel(b), "fr"),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats an aggregated quantity for display — French grouping/decimal
|
||||||
|
* conventions, at most 2 decimals (e.g. `"1,5"`, `"250"`) so summing several
|
||||||
|
* recipes' quantities (`shopping-list.service.ts`'s `aggregateShoppingList`,
|
||||||
|
* floating-point addition) never surfaces a long trailing-digit artifact
|
||||||
|
* like `"149.99999999999997"`.
|
||||||
|
*/
|
||||||
|
export function formatShoppingListQuantity(quantity: number): string {
|
||||||
|
return quantity.toLocaleString("fr-FR", { maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
@ -27,9 +27,6 @@ services:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: apps/api/Dockerfile
|
dockerfile: apps/api/Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
|
|
@ -51,8 +48,63 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,8 @@ 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. */
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ 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";
|
||||||
|
|
@ -21,6 +22,7 @@ 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,6 +117,41 @@ 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
|
||||||
|
|
@ -129,6 +164,19 @@ export type ListRecipesInput = z.infer<typeof listRecipesSchema>;
|
||||||
* (`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({
|
||||||
|
|
@ -136,6 +184,8 @@ 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",
|
||||||
|
|
@ -148,6 +198,15 @@ 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>;
|
||||||
|
|
|
||||||
19
packages/shared/src/schemas/shopping-list.ts
Normal file
19
packages/shared/src/schemas/shopping-list.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// See schemas/auth.ts for the shared client/server validation rationale.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload accepted by `GET /shopping-list`'s `?date=` query param — same
|
||||||
|
* shape/rationale as `schemas/planning.ts`'s `getPlanningByDateSchema`
|
||||||
|
* (only checks the `YYYY-MM-DD` shape, real-calendar-date validation is
|
||||||
|
* service-side via `@batch-cooking/date-tools`'s `parseDateOnly`). Kept as
|
||||||
|
* its own schema rather than importing `getPlanningByDateSchema` — each
|
||||||
|
* router module owns its own request contract in this repo, even when two
|
||||||
|
* happen to share a shape (see the two near-identical `date` fields already
|
||||||
|
* inside `schemas/planning.ts` itself).
|
||||||
|
*/
|
||||||
|
export const getShoppingListSchema = z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date invalide"),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link getShoppingListSchema}'s validated output. */
|
||||||
|
export type GetShoppingListInput = z.infer<typeof getShoppingListSchema>;
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
import type { AllergyView, DietView, IngredientView, TechStepView, UnitView } from "./reference.js";
|
import type {
|
||||||
|
AllergyView,
|
||||||
|
DietView,
|
||||||
|
IngredientView,
|
||||||
|
TechStepView,
|
||||||
|
UnitView,
|
||||||
|
UtensilView,
|
||||||
|
} from "./reference.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Who can *read* a recipe — mirrors `RecipeVisibility` in schema.prisma.
|
* Who can *read* a recipe — mirrors `RecipeVisibility` in schema.prisma.
|
||||||
|
|
@ -49,6 +56,11 @@ 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;
|
||||||
|
|
@ -57,6 +69,38 @@ 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,6 +210,24 @@ export interface TechStepView {
|
||||||
key: string;
|
key: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cooking utensil, as returned by `GET /reference/utensils` — reference
|
||||||
|
* data (`Utensil`, seeded via `reference-seed-data.ts`'s `UTENSILS`), same
|
||||||
|
* bare `id`+`key` shape and static/non-administrable status as
|
||||||
|
* {@link TechStepView}. Detected in a step's free text the same way
|
||||||
|
* techniques are (see `StepTechStepUtensilView`), but via a static
|
||||||
|
* `PhraseMatcher` rather than a trained classifier — see
|
||||||
|
* `services/tech-step-intent-service`'s `utensil_vocabulary.py`.
|
||||||
|
*
|
||||||
|
* `key` is a stable English camelCase uid (e.g. `"pan"`), not a display
|
||||||
|
* label — resolved via `t(\`catalog.utensils.${key}\`)`, same as
|
||||||
|
* {@link TechStepView.key}.
|
||||||
|
*/
|
||||||
|
export interface UtensilView {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An implemented recipe source, as returned by `GET /reference/sources` —
|
* An implemented recipe source, as returned by `GET /reference/sources` —
|
||||||
* reference data (`Source`, kept in sync with the adapter registry by
|
* reference data (`Source`, kept in sync with the adapter registry by
|
||||||
|
|
|
||||||
37
packages/shared/src/types/shopping-list.ts
Normal file
37
packages/shared/src/types/shopping-list.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
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,9 +44,6 @@ 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
|
||||||
|
|
@ -853,224 +850,12 @@ 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}
|
||||||
|
|
@ -1333,10 +1118,6 @@ 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}
|
||||||
|
|
||||||
|
|
@ -1506,10 +1287,6 @@ 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'}
|
||||||
|
|
@ -1613,9 +1390,6 @@ 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}
|
||||||
|
|
||||||
|
|
@ -1657,9 +1431,6 @@ 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'}
|
||||||
|
|
@ -1748,10 +1519,6 @@ 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'}
|
||||||
|
|
@ -1822,10 +1589,6 @@ 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'}
|
||||||
|
|
@ -1940,11 +1703,6 @@ 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'}
|
||||||
|
|
@ -2132,9 +1890,6 @@ 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'}
|
||||||
|
|
@ -2401,10 +2156,6 @@ 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'}
|
||||||
|
|
@ -2510,9 +2261,6 @@ 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'}
|
||||||
|
|
@ -2558,10 +2306,6 @@ 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'}
|
||||||
|
|
@ -2814,9 +2558,6 @@ 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'}
|
||||||
|
|
@ -3081,9 +2822,6 @@ 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'}
|
||||||
|
|
@ -3633,10 +3371,6 @@ 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'}
|
||||||
|
|
@ -3977,14 +3711,6 @@ 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}
|
||||||
|
|
||||||
|
|
@ -4006,11 +3732,6 @@ 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'}
|
||||||
|
|
@ -4064,9 +3785,6 @@ 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}
|
||||||
|
|
||||||
|
|
@ -4675,344 +4393,9 @@ 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':
|
||||||
|
|
@ -5199,8 +4582,6 @@ 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
|
||||||
|
|
@ -5423,8 +4804,6 @@ 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)
|
||||||
|
|
@ -5514,10 +4893,6 @@ 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: {}
|
||||||
|
|
@ -5554,8 +4929,6 @@ 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: {}
|
||||||
|
|
@ -5654,11 +5027,6 @@ 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
|
||||||
|
|
@ -5738,8 +5106,6 @@ 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
|
||||||
|
|
@ -5821,8 +5187,6 @@ 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
|
||||||
|
|
@ -6067,8 +5431,6 @@ 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
|
||||||
|
|
@ -6461,8 +5823,6 @@ 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: {}
|
||||||
|
|
@ -6584,8 +5944,6 @@ 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
|
||||||
|
|
@ -6626,14 +5984,6 @@ 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
|
||||||
|
|
@ -6876,12 +6226,6 @@ 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: {}
|
||||||
|
|
@ -7133,26 +6477,6 @@ 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:
|
||||||
|
|
@ -7751,10 +7075,6 @@ 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
|
||||||
|
|
@ -8079,10 +7399,6 @@ 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: {}
|
||||||
|
|
@ -8107,16 +7423,6 @@ 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: {}
|
||||||
|
|
@ -8174,6 +7480,4 @@ 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: {}
|
||||||
|
|
|
||||||
11
services/tech-step-intent-service/.env.example
Normal file
11
services/tech-step-intent-service/.env.example
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# 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
Normal file
5
services/tech-step-intent-service/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.env
|
||||||
34
services/tech-step-intent-service/Dockerfile
Normal file
34
services/tech-step-intent-service/Dockerfile
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# 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"]
|
||||||
198
services/tech-step-intent-service/README.md
Normal file
198
services/tech-step-intent-service/README.md
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
# 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.
|
||||||
282
services/tech-step-intent-service/augment_utterances.py
Normal file
282
services/tech-step-intent-service/augment_utterances.py
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
"""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()
|
||||||
12
services/tech-step-intent-service/intent_service/__init__.py
Normal file
12
services/tech-step-intent-service/intent_service/__init__.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
"""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`).
|
||||||
|
"""
|
||||||
52
services/tech-step-intent-service/intent_service/config.py
Normal file
52
services/tech-step-intent-service/intent_service/config.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""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()
|
||||||
|
|
@ -0,0 +1,481 @@
|
||||||
|
"""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])
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""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)
|
||||||
39
services/tech-step-intent-service/intent_service/main.py
Normal file
39
services/tech-step-intent-service/intent_service/main.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""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)
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""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()
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""`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")
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""`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,
|
||||||
|
)
|
||||||
54
services/tech-step-intent-service/intent_service/schemas.py
Normal file
54
services/tech-step-intent-service/intent_service/schemas.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""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
|
||||||
35
services/tech-step-intent-service/intent_service/security.py
Normal file
35
services/tech-step-intent-service/intent_service/security.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""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")
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""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()
|
||||||
2039
services/tech-step-intent-service/intent_service/training_data.py
Normal file
2039
services/tech-step-intent-service/intent_service/training_data.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,197 @@
|
||||||
|
"""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)
|
||||||
|
}
|
||||||
53
services/tech-step-intent-service/pyproject.toml
Normal file
53
services/tech-step-intent-service/pyproject.toml
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
[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
|
||||||
31
services/tech-step-intent-service/tests/conftest.py
Normal file
31
services/tech-step-intent-service/tests/conftest.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""`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
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
"""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"
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
"""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"
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""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
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""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}
|
||||||
37
services/tech-step-intent-service/tests/test_security.py
Normal file
37
services/tech-step-intent-service/tests/test_security.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""`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
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""Réplique les cas de `normalizeText` de `tech-step-matcher.test.ts`
|
||||||
|
(`apps/api/test/recipe-matching/tech-step-matcher.test.ts`) contre le port
|
||||||
|
Python — les deux fonctions doivent rester bit-pour-bit équivalentes."""
|
||||||
|
|
||||||
|
from intent_service.text_normalization import normalize_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_lowercases_and_strips_accents():
|
||||||
|
assert normalize_text("Déglacer AU FOUR") == "deglacer au four"
|
||||||
|
|
||||||
|
|
||||||
|
def test_strips_a_variety_of_diacritics_including_cedilla():
|
||||||
|
assert normalize_text("Façon Œuf à l'Étouffée") == "facon œuf a l'etouffee"
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaves_already_plain_text_unchanged_aside_from_casing():
|
||||||
|
assert normalize_text("Mix everything") == "mix everything"
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_an_empty_string_for_an_empty_input():
|
||||||
|
assert normalize_text("") == ""
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Garde-fou de non-régression pour l'équilibrage du corpus (voir
|
||||||
|
`training_data.py`'s propre commentaire de tête) : chaque technique doit
|
||||||
|
avoir exactement le même nombre d'`utterances` que chaque autre, par
|
||||||
|
locale — un déséquilibre entre classes est une source réelle de
|
||||||
|
classifications confiantes mais fausses sur une phrase jamais vue (constaté
|
||||||
|
en pratique — voir l'historique Git de ce fichier, trois tentatives
|
||||||
|
d'équilibrer vers un nombre plus élevé ont toutes dégradé le F1 agrégé de
|
||||||
|
`test/recipe-matching/tech-step-eval.test.ts` avant que la stratégie
|
||||||
|
actuelle — équilibrer vers le maximum déjà présent dans le corpus, pas un
|
||||||
|
nombre choisi dans l'absolu — ne passe cette même gate)."""
|
||||||
|
|
||||||
|
from intent_service.training_data import TECH_STEP_TRAINING_DATA
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_technique_has_the_same_utterance_count_per_locale():
|
||||||
|
for locale in ("fr", "en"):
|
||||||
|
counts = {entry.uid: len(getattr(entry, locale).utterances) for entry in TECH_STEP_TRAINING_DATA}
|
||||||
|
distinct = set(counts.values())
|
||||||
|
assert len(distinct) == 1, (
|
||||||
|
f"utterance counts for locale {locale!r} aren't uniform across techniques "
|
||||||
|
f"(run augment_utterances.py to re-equalize): {counts}"
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""Couvre `LocalePipeline`'s second `PhraseMatcher` (ustensiles,
|
||||||
|
`utensil_vocabulary.py`) — même style que `test_locale_pipeline_entities.py`
|
||||||
|
(offsets exacts, insensibilité accents/casse), mais contre le vocabulaire
|
||||||
|
*réel* (`UTENSIL_VOCABULARY`, statique, construit par `preload()` — pas
|
||||||
|
besoin d'un jeu de test dédié comme pour les techniques, voir
|
||||||
|
`LocalePipeline.preload`'s own comment)."""
|
||||||
|
|
||||||
|
from intent_service.locale_pipeline import LocalePipeline, TrainEntry
|
||||||
|
|
||||||
|
# Un `train()` minimal suffit — le `PhraseMatcher` d'ustensiles est
|
||||||
|
# construit par `preload()` (appelé par `train()`), indépendamment du
|
||||||
|
# `TrainEntry` de techniques passé ici (voir `preload()`'s own comment sur
|
||||||
|
# pourquoi les deux ne sont pas couplés).
|
||||||
|
_MINIMAL_ENTRIES = [
|
||||||
|
TrainEntry(uid="melt", synonyms=["fondre"], utterances=["faire fondre le beurre"]),
|
||||||
|
TrainEntry(uid="simmer", synonyms=["mijoter"], utterances=["faire mijoter à feu doux"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _fr_pipeline() -> LocalePipeline:
|
||||||
|
pipeline = LocalePipeline("fr")
|
||||||
|
pipeline.train(_MINIMAL_ENTRIES)
|
||||||
|
return pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def test_matches_a_real_utensil_with_exact_span():
|
||||||
|
pipeline = _fr_pipeline()
|
||||||
|
text = "Dans une poêle chaude, faire fondre le beurre"
|
||||||
|
result = pipeline.process(text)
|
||||||
|
|
||||||
|
pan_entities = [e for e in result.entities if e.uid == "pan"]
|
||||||
|
assert len(pan_entities) == 1
|
||||||
|
entity = pan_entities[0]
|
||||||
|
assert entity.kind == "utensil"
|
||||||
|
assert text[entity.start : entity.end] == "poêle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_case_and_accent_insensitive():
|
||||||
|
pipeline = _fr_pipeline()
|
||||||
|
result = pipeline.process("Verser dans la POÊLE")
|
||||||
|
utensil_uids = [e.uid for e in result.entities if e.kind == "utensil"]
|
||||||
|
assert utensil_uids == ["pan"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matches_a_multi_word_synonym():
|
||||||
|
pipeline = _fr_pipeline()
|
||||||
|
text = "Découper les légumes sur la planche à découper"
|
||||||
|
result = pipeline.process(text)
|
||||||
|
board_entities = [e for e in result.entities if e.uid == "cuttingBoard"]
|
||||||
|
assert len(board_entities) == 1
|
||||||
|
assert text[board_entities[0].start : board_entities[0].end] == "planche à découper"
|
||||||
|
|
||||||
|
|
||||||
|
def test_technique_and_utensil_are_both_returned_without_interfering():
|
||||||
|
pipeline = _fr_pipeline()
|
||||||
|
text = "Dans une casserole, faire mijoter à feu doux"
|
||||||
|
result = pipeline.process(text)
|
||||||
|
|
||||||
|
kinds_by_uid = {e.uid: e.kind for e in result.entities}
|
||||||
|
assert kinds_by_uid.get("simmer") == "technique"
|
||||||
|
assert kinds_by_uid.get("saucepan") == "utensil"
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_no_utensil_entities_when_none_are_mentioned():
|
||||||
|
pipeline = _fr_pipeline()
|
||||||
|
result = pipeline.process("Laisser reposer la pâte une heure")
|
||||||
|
assert [e for e in result.entities if e.kind == "utensil"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_matches_english_utensils_too():
|
||||||
|
pipeline = LocalePipeline("en")
|
||||||
|
pipeline.train([TrainEntry(uid="chop", synonyms=["chop"], utterances=["chop the onions finely"])])
|
||||||
|
text = "Heat the pan before adding the onions"
|
||||||
|
result = pipeline.process(text)
|
||||||
|
pan_entities = [e for e in result.entities if e.uid == "pan"]
|
||||||
|
assert len(pan_entities) == 1
|
||||||
|
assert pan_entities[0].kind == "utensil"
|
||||||
|
assert text[pan_entities[0].start : pan_entities[0].end] == "pan"
|
||||||
1560
services/tech-step-intent-service/uv.lock
Normal file
1560
services/tech-step-intent-service/uv.lock
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue