* feat(recipes): migre la detection des tech steps de node-nlp vers un microservice Python spaCy Remplace TechStepClassifierService's node-nlp (NlpManager) par services/tech-step-intent-service, un microservice FastAPI/spaCy dedie (PhraseMatcher pour le NER par synonymes, textcat pour la classification d'intention). Corpus (TECH_STEP_TRAINING_DATA) toujours possede par apps/api, pousse au service via POST /v1/train a chaque warm-up ; le service ne touche jamais Postgres (meme posture que services/tech-step-llm-worker). Cote apps/api : - intent-service-client.ts : client HTTP vers le nouveau service - tech-step-matcher.ts : delegue NER + intent classification au client, logique pure (splitIntoClauses, seuil/fallback) inchangee - env.ts : INTENT_SERVICE_BASE_URL/INTENT_SERVICE_SECRET (secret requis, service coeur non optionnel) - server.ts : warm-up avec retry/backoff (service Python demarre a part) - scripts/calibrate-tech-step-threshold.ts : recalibration empirique de CONFIDENCE_THRESHOLD contre le jeu d'eval existant - node-nlp retire (package.json, node-nlp.d.ts, model.nlp du .gitignore) docker-compose.yml : nouveau service tech-step-intent-service (pas de port expose, healthcheck, app en depend). CI : job intent-service-test (pytest) + le job test demarre le service en arriere-plan avant la suite Mocha (jamais de mock d'un service interne, cf specs/dev-conventions.md). Verifie : 26/26 tests pytest du service (dont les offsets caracteres exacts de tech-step-matcher.test.ts), lint + build complets du monorepo, smoke test HTTP reel bout en bout. La suite Mocha et docker compose build/up n'ont pas pu etre executes dans cet environnement (pas de Postgres/Docker disponibles ici) — a confirmer via la CI et en local. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): corrige les matches dupliques et le timeout de warm-up des tests CI Deux bugs reels trouves par la premiere execution CI de la migration node-nlp -> tech-step-intent-service : 1. PhraseMatcher retourne tous les matches y compris chevauchants — un synonyme comme "fondre" litteralement contenu dans "faire fondre" (tous deux synonymes de `melt`) produisait deux candidats separes pour la meme technique, dupliquant son techStepId dans le resultat final. Fixe avec spacy.util.filter_spans (garde le plus long match par position) dans LocalePipeline.process. Test de non-regression ajoute. 2. La suite Mocha construit `app` directement via createApp(), sans jamais passer par server.ts — le warm-up (POST /v1/train fr+en sur le corpus complet) se declenchait donc paresseusement dans le premier test qui appelait le classifieur, depassant le timeout Mocha de 10s par test. Fixe par un root hook plugin Mocha (test-support/mocha-root-hooks.ts, .mocharc.json) qui reset la DB et warm up le classifieur une seule fois avant toute suite, avec son propre timeout de 60s. Verifie : 27/27 tests pytest du service (dont le nouveau test de non-regression), lint + build complets du monorepo. La suite Mocha elle-meme n'a toujours pas pu etre executee dans cet environnement (pas de Postgres disponible ici) — a confirmer via la CI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * ci(temp): ajoute un run de calibrate-tech-step-threshold.ts pour observation Etape temporaire pour lire le sweep de seuils de confiance contre le vrai service tech-step-intent-service en CI (aucun Postgres/service disponible localement dans cette session) — sera retiree une fois CONFIDENCE_THRESHOLD recalibre dans tech-step-matcher.ts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): recalibre CONFIDENCE_THRESHOLD pour le nouveau classifieur spaCy 0.75 (calibre a l'origine contre node-nlp) laissait de vrais verdicts corrects sur des clauses sans ancre NER (rien sur quoi retomber) sous le seuil : melt scorait 0.68 sur "jusqu'a ce que le beurre ait disparu dans la poele" (le cas motivant tout ce pipeline), preheat 0.52 sur "mettre la poele sur feu vif" — tous deux corrects, tous deux rejetes a 0.75. Recalibre a 0.45 : marge confortable au-dessus du bruit (texte anglais via le classifieur francais score ~0.04, indiscernable du hasard sur ~26 classes) et sous les deux cas ci-dessus. Confirme par calibrate-tech-step-threshold.ts contre TECH_STEP_EVAL_DATASET (F1 plafonne a 0.987 des 0.45, reste plat jusqu'a 0.95 — 0.45 est deja le seuil le plus bas qui capture tout le gain disponible). Retire l'etape CI temporaire de calibration (ci.yml) une fois la valeur choisie. Verifie : lint + build complets du monorepo, 27/27 pytest du service, sweep de seuils + verification manuelle contre le corpus reel en local (services Python, sans Postgres) et en CI. La suite Mocha complete reste a confirmer sur ce commit (executee en CI, pas localement — pas de Postgres disponible dans cet environnement). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): entraine le textcat plus longtemps pour une confiance reelle Cause racine du dernier test Mocha en echec (getAuditBatch flaguait "Faire mijoter a feu doux" comme peu fiable malgre une ancre NER claire) : avec seulement 30 iterations/dropout 0.2, le textcat retournait le bon intent (argmax correct) mais avec une confiance tres basse et compressee (0.2-0.7 sur l'ensemble du corpus reel, y compris des cas evidents) — un vrai probleme de qualite d'entrainement, pas seulement de seuil. 150 iterations / lot de 16 / dropout 0.1 (mesure localement contre le vrai corpus, sans Postgres) : melt ~0.95, preheat ~0.90, jusqu'a ~0.51 pour le cas le plus faible observe (bake), bruit hors-vocabulaire toujours ~0.05. ~110s d'entrainement par locale (~220s pour fr+en au warm-up) — compromis assume et documente (README du service, commentaires du code), contrairement a l'entrainement quasi instantane de node-nlp. Root hook Mocha (mocha-root-hooks.ts) et sa doc mis a jour avec un timeout de 600s pour couvrir cette duree avec marge. Verifie : 27/27 pytest, lint + build complets du monorepo. Suite Mocha a confirmer sur ce commit via CI (source du diagnostic qui a mene a ce fix). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(recipes): journalise chaque input/output du pipeline NLP Ajoute un logging JSON structure (meme convention que LoggerService cote apps/api) a services/tech-step-intent-service : chaque appel POST /v1/process journalise locale/texte en entree et entites/intent/score en sortie, chaque POST /v1/train journalise les uid entraines et les compteurs resultants. Chatter interne de spaCy mis a WARNING pour ne pas noyer ces lignes. Bug trouve et corrige en verifiant les octets bruts d'un log reel (pas juste son affichage terminal) : l'encodage par defaut de sys.stdout sur Windows produisait de vrais octets UTF-8 invalides pour tout texte accentue journalise (le francais des etapes de recette) — corrige par sys.stdout.reconfigure(encoding="utf-8") au demarrage. LOG_LEVEL configurable (INFO par defaut), documente dans le README du service et .env.example. Verifie : 30/30 pytest (3 nouveaux tests sur le formateur JSON), smoke test HTTP reel confirmant au niveau des octets que les caracteres accentues sont preserves, lint complet du monorepo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(recipes): rapatrie le corpus NLP cote Python et l'enrichit de 48 techniques Changement d'architecture demande par l'utilisateur : le dataset d'entrainement (TECH_STEP_TRAINING_DATA) quitte apps/api pour vivre entierement dans services/tech-step-intent-service (intent_service/training_data.py). Ce service est desormais autonome : il s'entraine lui-meme une seule fois, a son propre demarrage (PipelineRegistry.initialize, dans le lifespan FastAPI), sans plus dependre d'un POST /v1/train pousse par apps/api (route supprimee). apps/api ne connait plus aucune technique/synonyme, uniquement le resultat de POST /v1/process. Corpus enrichi avec les 48 techniques du lexique fourni (Arroser, Appertiser, Braiser, Caraméliser, Confire, Julienne/Brunoise/Mirepoix/ Paysanne, Cuire à blanc/au bain-marie/à l'étouffée, Déglacer variantes, Emulsionner, Glacer, Pocher, Réduire, Suer, Zester, etc.), soit 74 techniques au total (26 + 48). Integration complete bout en bout : - reference-seed-data.ts : 48 nouvelles entrees TECH_STEPS - apps/web/locales/fr/translation.json : libelles francais correspondants - "Mitonner" fondu comme synonyme de simmer (pas une technique distincte, sa propre definition le dit) - "Blanchir un oeuf" (whiskPale) distingue de "Blanchir un legume" (blanch, existant) via des synonymes en phrase complete plutot qu'au mot nu — filter_spans (deja en place) resout la collision par specificite Impact performance mesure : le corpus elargi (74 classes vs 26) rend l'entrainement bien plus lent a nombre d'iterations egal (150 iterations depassait 17 minutes par run de test) — reduit a 40 iterations apres mesures repetees en local (~200s/locale, ~400s pour fr+en combines). docker-compose.yml (healthcheck start_period 600s), CI (timeout curl 600s) et le README du service documentent ce nouveau temps de demarrage. CONFIDENCE_THRESHOLD recalibre a 0.2 par verification manuelle (0.75 puis 0.45 ne tenaient plus compte tenu du nombre de classes) — marque explicitement comme placeholder en attendant une vraie repasse de calibrate-tech-step-threshold.ts (necessite Postgres, indisponible dans cet environnement). Verifie : 28/28 tests pytest du service (suite complete re-ecrite pour s'entrainer une seule fois par session sur le vrai corpus, fixture partagee dans conftest.py), lint + build complets du monorepo. La suite Mocha d'apps/api reste a confirmer via CI (le root hook mocha n'attend plus l'entrainement, seulement CI's propre attente sur /health). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): corrige l'assertion de taille du catalogue TechStep en dur test/reference.test.ts attendait exactement 26 techniques (l'ancien catalogue) au lieu de deriver la longueur attendue de TECH_STEPS (reference-seed-data.ts) — trouve par la CI apres l'ajout des 48 nouvelles techniques (74 au total). Seul echec du run CI precedent, le service Python (nouveau corpus, self-training) a lui demarre et repondu correctement dans le nouveau delai imparti. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): entraine le textcat plus longtemps pour une confiance reelle Suite a une revue de code sur locale_pipeline.py, trois ameliorations implementees et verifiees contre le vrai corpus (74 techniques) : - spacy.util.fix_random_seed(_TRAINING_SEED) avant nlp.initialize() — random.Random() ne graine que l'ordre de melange des exemples, pas l'init des poids/dropout internes de thinc. - _DiacriticsNormalizer deplace au-dessus de sa factory @Language.factory — plus d'annotation de type en chaine. - Log explicite (logger.warning) quand train() recoit moins de 2 labels et saute la creation du textcat, plus une clarification de la docstring de process() sur les deux cas menant a intent=None. - Early stopping avec suivi de la perte par epoque, _TRAINING_ITERATIONS restant le plafond. Mesure sur le vrai corpus : ne se declenche jamais dans le budget actuel de 40 iterations (la perte continue de baisser significativement jusqu'au bout) — documente honnetement comme filet de securite pour un futur relevement du plafond, pas un gain de temps aujourd'hui. Deux suggestions de la revue examinees et non retenues, avec justification en commentaire : le risque de desalignement pattern/texte via normalize_text (normalize_text opere par token deja tokenise, jamais sur la chaine brute — pas de risque de segmentation differente) ; passer a attr="LOWER" aurait au contraire regresse l'insensibilite aux accents que attr="NORM" fournit deliberement. Verifie : 28/28 pytest (dont le vrai corpus complet via la fixture partagee), lint du monorepo. Temps d'entrainement mesure stable (~200-230s/locale, dans la marge de bruit deja documentee). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): entraine le textcat sur les synonymes en plus des utterances Suite a une suggestion de revue de code : le textcat n'apprenait jusqu'ici que sur entry.utterances, jamais sur entry.synonyms (deja utilises pour le PhraseMatcher). Ajouter le mot-cle isole comme exemple positif de sa propre technique ameliore radicalement la confiance sur les cas ancres sans paraphrase entrainee. Mesures sur le vrai corpus (74 techniques) : - 40 iterations + synonymes (749 exemples vs 286 avant) : gain de confiance massif (simmer 0.25->0.60, cook 0.33->0.60, bake 0.34->0.86) mais temps d'entrainement multiplie par 2.6 (~535s/locale, ~17min combine pour fr+en — inacceptable). - 15 iterations + synonymes : retour a un temps raisonnable (~205s) mais qualite pire qu'avant (simmer/cook repassent sous le seuil de confiance) — les exemples supplementaires ne compensent pas la perte d'epoques a ce point. - 25 iterations + synonymes (retenu) : ~336s/locale (~670s combine), meilleur compromis — tous les cas mesures s'ameliorent par rapport a la config precedente (simmer 0.25->0.31, cook 0.33->0.38, bake 0.34->0.62, zest 0.64->0.66, julienne 0.56->0.76, compote 0.76->0.78), bruit hors-vocabulaire toujours negligeable (~0.02). CONFIDENCE_THRESHOLD releve de 0.2 a 0.25 (le cas le plus faible mesure est maintenant 0.31, avec plus de marge qu'avant). docker-compose.yml (start_period 900s) et la CI (timeout 900s) ajustes pour le nouveau temps de demarrage (~11 min pour fr+en combines, contre ~7 min avant). Deux autres pistes de la meme revue examinees et non retenues avec justification : classe __OTHER__/negatifs hors-domaine (le bruit mesure est deja bas, ~0.02, sans le symptome que cette classe corrige) et boost de score post-traitement si le NER confirme l'intention predite (casserait la garantie "score brut, jamais corrige par l'ancre" que services/tech-step-llm-worker's audit de faible confiance depend explicitement d'avoir, voir le commentaire de TechStepClauseClassification dans tech-step-matcher.ts). Verifie : 28/28 pytest (dont le vrai corpus complet, ~10.5 min pour la suite complete), lint + build du monorepo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
501 lines
27 KiB
Markdown
501 lines
27 KiB
Markdown
# batchCooking
|
||
|
||
## Structure
|
||
|
||
Monorepo pnpm workspaces :
|
||
|
||
- `apps/api` — backend Express/TypeScript : auth, foyer, planning (grille de la
|
||
semaine), catalogue de recettes (favoris/perso/foyer/publique), import de
|
||
recettes depuis des sources externes, préférences (thème, régime, allergies,
|
||
ingrédients détestés). Tests Mocha (base Postgres réelle, isolée de la base
|
||
de dev — voir plus bas).
|
||
- `apps/web` — frontend React/Vite/TypeScript, prêt à être embarqué par
|
||
Capacitor plus tard. Espace connecté complet (planning, recettes, réglages)
|
||
derrière une sidebar, wizard d'inscription, thème clair/sombre/système.
|
||
- `packages/shared` — code partagé entre `api` et `web` : schémas zod, types
|
||
(`RecipeView`, `PlanningView`, `HouseView`, `SafeUserProfile`...), le contrat
|
||
d'erreurs (`ErrorCode` numérique, `ApiErrorResponse`, voir
|
||
[specs/error-handling.md](specs/error-handling.md)) et les libellés anglais
|
||
du catalogue d'ingrédients (`data/catalog-labels-en.ts`, utilisés par le
|
||
matching de recettes importées — voir plus bas) — même règles des deux
|
||
côtés, pas de risque de dérive entre front et back.
|
||
- `packages/error-tools` — gestion des erreurs, **indépendante de tout framework
|
||
HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Séparé
|
||
d'`express-tools` précisément parce que rien ici ne dépend d'Express. Détail :
|
||
[specs/error-handling.md](specs/error-handling.md).
|
||
- `packages/express-tools` — outillage Express générique et réutilisable : `ExpressServer`
|
||
(init serveur, routes, middlewares), `wrapAsyncHandler`, `createErrorMiddleware`
|
||
(adapte `ErrorHandlerService` de `error-tools` à Express) — séparé d'`apps/api`,
|
||
pas de logique métier. Détail : [specs/backend-architecture.md](specs/backend-architecture.md).
|
||
- `packages/date-tools` — utilitaires de date partagés (Luxon) : convention
|
||
"date-only = minuit UTC" (`parseDateOnly`/`formatDateOnly`/`toDateOnly`),
|
||
calcul de semaine lundi-first (`getWeekStart`/`addWeeks`/`buildCalendarMonth`)
|
||
— utilisés à la fois par `apps/api` (validation de date de planning) et
|
||
`apps/web` (grille/navigateur de semaine).
|
||
|
||
`packages/shared`, `packages/error-tools`, `packages/express-tools` et
|
||
`packages/date-tools` ont un vrai build (`tsc` → `dist/`, voir leur
|
||
`package.json`) : consommés en JS compilé, pas en TS brut — nécessaire pour un
|
||
runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans
|
||
[specs/frontend-architecture.md](specs/frontend-architecture.md#note-sur-les-fichiers-dts).
|
||
|
||
## Prérequis
|
||
|
||
- Node.js 22 (voir `.nvmrc`)
|
||
- pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle)
|
||
- Docker (pour Postgres en local)
|
||
- Python 3.12+ et [`uv`](https://docs.astral.sh/uv/) (pour
|
||
`services/tech-step-intent-service` en dev natif — requis, voir plus bas)
|
||
|
||
## Installation
|
||
|
||
```bash
|
||
pnpm install
|
||
cp .env.example .env
|
||
cp apps/api/.env.example apps/api/.env
|
||
cp apps/web/.env.example apps/web/.env
|
||
```
|
||
|
||
Puis **édite ces deux `.env`** pour renseigner de vrais `POSTGRES_USER`/`POSTGRES_PASSWORD`
|
||
(et la `DATABASE_URL` correspondante dans `apps/api/.env`) : les fichiers `.env.example`
|
||
ne contiennent volontairement aucun identifiant réel (juste `changeme`), et
|
||
`docker-compose.yml` refuse de démarrer tant que `POSTGRES_USER`/`PASSWORD`/`DB` ne
|
||
sont pas définis dans `.env` — pas de valeur par défaut en dur dans les fichiers commités.
|
||
Même règle pour `apps/api/.env` : `JWT_SECRET` est **requis, sans défaut** (génère le
|
||
tien, voir le commentaire dans `apps/api/.env.example`).
|
||
|
||
Si tu comptes lancer `pnpm --filter api test` (voir [Qualité / Tests](#qualité--tests)),
|
||
crée aussi `apps/api/.env.test` — voir la section dédiée plus bas.
|
||
|
||
### Cypress : téléchargement du binaire
|
||
|
||
`pnpm install` installe le package `cypress` mais **pas forcément son binaire** (le
|
||
téléchargement du `.exe`/binaire natif peut être ignoré selon l'environnement où
|
||
`pnpm install` a été lancé — ex. un environnement sandboxé/CI dont le cache ne
|
||
correspond pas à celui de ta machine). Si `pnpm --filter web e2e` échoue avec une
|
||
erreur du type :
|
||
|
||
```
|
||
No version of Cypress is installed in: ...\AppData\Local\Cypress\Cache\...
|
||
Please reinstall Cypress by running: cypress install
|
||
```
|
||
|
||
lance simplement, depuis ta machine :
|
||
|
||
```bash
|
||
pnpm --filter web exec cypress install
|
||
```
|
||
|
||
(à faire une seule fois par machine ; le binaire est mis en cache localement,
|
||
hors du repo).
|
||
|
||
## Développement
|
||
|
||
```bash
|
||
# Base de données Postgres locale
|
||
docker compose up -d postgres
|
||
|
||
# Applique le schéma (première fois / après un changement de prisma/schema.prisma)
|
||
pnpm --filter api exec prisma migrate dev
|
||
|
||
# Peuple les données de référence (régimes, allergènes, ingrédients, unités,
|
||
# techniques...) — automatique après `prisma migrate reset`, sinon à la main :
|
||
pnpm --filter api prisma:seed
|
||
|
||
# Microservice de détection des techniques (spaCy) — requis, `pnpm dev:api`
|
||
# ne peut plus détecter aucune technique de cuisine sans lui. Lance-le en
|
||
# premier et laisse-le tourner : il s'entraîne lui-même à chaque démarrage
|
||
# (~11 minutes pour le corpus actuel, voir son propre README) avant de
|
||
# répondre quoi que ce soit sur /health.
|
||
cd services/tech-step-intent-service
|
||
uv sync
|
||
cp .env.example .env # édite-le : même INTENT_SERVICE_SECRET que apps/api/.env
|
||
uv run uvicorn intent_service.main:app --reload --port 8000
|
||
cd ../..
|
||
|
||
# Backend (http://localhost:3000)
|
||
pnpm dev:api
|
||
|
||
# Frontend (http://localhost:5173)
|
||
pnpm dev:web
|
||
```
|
||
|
||
> **Conflit de port possible sur `5432`** : si tu as déjà un Postgres natif installé
|
||
> sur ta machine (service Windows, Homebrew, etc.), il peut occuper le port 5432 et
|
||
> intercepter les connexions à la place du conteneur Docker (symptôme : Prisma
|
||
> renvoie `P1000: Authentication failed` alors que les identifiants sont corrects).
|
||
> Dans ce cas, mets `POSTGRES_PORT=5433` (ou autre) dans ton `.env` **et** adapte le
|
||
> port dans la `DATABASE_URL` de `apps/api/.env`.
|
||
|
||
> **Toujours cibler `postgres`, jamais `docker compose up -d` tout court.** Le même
|
||
> `docker-compose.yml` définit aussi le service `app` (voir [Déploiement](#déploiement)) —
|
||
> celui que Portainer construit en production. Sans nom de service, `docker compose up -d`
|
||
> démarre les deux : ça déclenche un `pnpm install` sur tout le monorepo (donc aussi le
|
||
> `cypress` d'`apps/web`, avec son téléchargement de binaire) rien que pour builder une
|
||
> image dont le dev local n'a pas besoin (on sert le front/back directement via
|
||
> `pnpm dev:web`/`pnpm dev:api`, pas ce conteneur).
|
||
|
||
## Qualité / Tests
|
||
|
||
Conventions de code (classes vs objets littéraux, préfixe `_` sur les membres
|
||
privés, règles Biome actives, logs côté serveur, etc.) :
|
||
[specs/dev-conventions.md](specs/dev-conventions.md).
|
||
|
||
```bash
|
||
pnpm lint # Biome (lint + format check)
|
||
pnpm lint:fix # Biome --write
|
||
pnpm test # tests unitaires/intégration (Mocha, apps/api)
|
||
pnpm --filter web e2e # tests e2e (Cypress + Cucumber, démarre le serveur dev automatiquement)
|
||
pnpm --filter web cy:run:component # tests de composant UI isolés (Cypress component testing)
|
||
pnpm build # build de tous les workspaces
|
||
```
|
||
|
||
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute cinq jobs indépendants (`lint`, `test`, `intent-service-test`, `build`, `e2e` — ce dernier lance aussi `cy:run:component`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. `test` démarre `services/tech-step-intent-service` en arrière-plan (voir ce fichier) puisque la suite Mocha ne mocke jamais un service interne. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
|
||
|
||
### Base de test isolée de la base de dev (`apps/api`)
|
||
|
||
`pnpm --filter api test` exécute une `TRUNCATE ... CASCADE` sur presque tout le
|
||
schéma **avant chaque test** (`test-support/reset-db.ts`). Pour ne jamais
|
||
risquer de vider une vraie base de dev locale, `NODE_ENV=test` (posé par le
|
||
script `test`) fait charger `apps/api/.env.test` au lieu de `.env` — un
|
||
fichier **à créer toi-même**, pas fourni automatiquement :
|
||
|
||
```bash
|
||
cp apps/api/.env.test.example apps/api/.env.test
|
||
# puis édite-le : mêmes identifiants Postgres que ton .env, mais une base
|
||
# différente (ex. batchcooking_test) — .env.test.example documente les
|
||
# commandes exactes pour la créer et lui appliquer le schéma.
|
||
```
|
||
|
||
Un garde-fou (`assertRunningAgainstTestDatabase()`) refuse d'exécuter
|
||
`resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la
|
||
seule base qu'il doit rejeter est ta vraie base de dev.
|
||
|
||
`services/tech-step-intent-service` doit aussi tourner en local avant
|
||
`pnpm --filter api test` — les tests touchant `tech-step-matcher.ts` passent
|
||
par le vrai service (jamais un mock, voir
|
||
[specs/dev-conventions.md](specs/dev-conventions.md)) et échouent avec une
|
||
erreur de connexion, pas une assertion utile, s'il n'est pas démarré. Voir la
|
||
section [Développement](#développement) ci-dessus.
|
||
|
||
## Déploiement
|
||
|
||
Une seule image Docker (`apps/api/Dockerfile`) sert à la fois l'API et le frontend
|
||
buildé — plus de conteneur nginx séparé pour `apps/web`. Le stage `build` compile
|
||
`apps/api` **et** `apps/web` (`pnpm --filter web build`), le stage `runtime` copie
|
||
le résultat (`apps/web/dist`) à côté de l'API ; au démarrage, `apps/api/src/app.ts`
|
||
sert ce dossier statique (fallback SPA compris, pour le routing react-router côté
|
||
client) via `FRONTEND_DIST_DIR` — voir `packages/express-tools/src/express-server.ts`
|
||
(`serveStaticFrontend`). Cette variable n'est renseignée que dans l'image Docker :
|
||
en dev natif (`pnpm dev:api`), elle reste vide et `pnpm dev:web` continue de servir
|
||
le frontend via son propre serveur Vite (HMR), sur un port séparé, comme avant.
|
||
|
||
Le `CMD` de l'image enchaîne trois étapes, chacune dans son propre processus
|
||
`node` : `prisma migrate deploy` (applique les migrations), puis
|
||
`node dist/scripts/seed-runtime.js` (seed des données de référence **et**
|
||
synchronisation de la table `sources` depuis le registre d'adaptateurs de code
|
||
— nécessaire à chaque démarrage : le registre en mémoire peuplé par
|
||
`server.ts` ne survit pas au changement de processus, voir
|
||
[specs/backend-architecture.md](specs/backend-architecture.md#sources-externes--adaptateur-registre-synchronisation)),
|
||
puis `node dist/server.js`. Les trois étapes sont sûres/idempotentes à
|
||
répéter à chaque redémarrage du conteneur.
|
||
|
||
Le duo `postgres`/`app` de `docker-compose.yml` n'expose donc qu'un seul port
|
||
applicatif, `APP_PORT` (défaut `3000`) — plus de `WEB_PORT`/`CORS_ORIGIN` à
|
||
coordonner entre deux origines, le frontend et l'API sont désormais servis
|
||
depuis la même origine. Les deux autres services du fichier
|
||
(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
|
||
au host — voir leurs propres README pour leur rôle.
|
||
|
||
**Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
|
||
production est reliée directement au dépôt Git et reconstruit elle-même
|
||
`docker-compose.yml`/`apps/api/Dockerfile` à chaque déploiement — la CI ne pousse
|
||
donc aucune image nulle part.
|
||
|
||
### Release (`.github/workflows/release.yml`)
|
||
|
||
Déclenchée par un tag `vX.Y.Z` :
|
||
|
||
```bash
|
||
git tag vX.Y.Z
|
||
git push --tags
|
||
```
|
||
|
||
Le pipeline enchaîne trois jobs : `sanity-build` (build de l'image Docker sans
|
||
push, juste pour vérifier qu'elle build encore à ce tag avant de laisser Portainer
|
||
redéployer dessus), `github-release` (crée une Release GitHub avec changelog
|
||
auto-généré à partir des PRs mergées), puis `notify-portainer` — envoie une requête
|
||
au webhook de redeploy de Portainer si le secret de dépôt `PORTAINER_WEBHOOK_URL`
|
||
est configuré (sinon Portainer se resynchronise simplement à son prochain
|
||
polling Git). Pour l'activer : récupérer l'URL du webhook depuis les réglages du
|
||
stack Portainer, puis l'ajouter comme secret GitHub `PORTAINER_WEBHOOK_URL`.
|
||
|
||
## Auth (apps/api)
|
||
|
||
Inscription (création de profil + foyer) et connexion, JWT dans un cookie httpOnly.
|
||
|
||
- `POST /auth/signup` — `{ firstName, lastName, email, password }` → crée le foyer
|
||
(`house`) et le profil (`user_profiles`) en une transaction, pose le cookie de
|
||
session, renvoie le profil (201)
|
||
- `POST /auth/login` — `{ email, password }` → pose le cookie de session, renvoie le
|
||
profil (200) ; message d'erreur volontairement générique (401) que ce soit
|
||
l'email ou le mot de passe qui soit incorrect
|
||
- `POST /auth/logout` — efface le cookie (204)
|
||
- `GET /auth/me` — profil courant, nécessite le cookie de session (401 sinon)
|
||
- `DELETE /auth/me` — supprime définitivement le compte après re-saisie du mot
|
||
de passe (`{ password }`, 401 `INVALID_CREDENTIALS` si incorrect) ; gère le
|
||
départ/transfert d'adminship du foyer avant suppression (voir Foyer plus bas)
|
||
|
||
Mots de passe hachés avec argon2. `UserProfile.tokenVersion` existe pour
|
||
invalider les JWT déjà émis (ex. futur changement de mot de passe) mais rien
|
||
ne l'incrémente encore — pas de route de changement d'email/mot de passe
|
||
aujourd'hui, seulement la suppression de compte.
|
||
|
||
> **argon2 : version pinnée à `0.31.2`, pas de `^`.** La version `0.45.1` (dernière au
|
||
> moment de l'écriture) segfault au runtime sur au moins une configuration Windows —
|
||
> reproduit de façon stable (bash sandboxé, bash non-sandboxé, PowerShell), alors que
|
||
> `0.31.2` fonctionne parfaitement avec la même API. Si tu montes la version, revérifie
|
||
> concrètement (`argon2.hash(...)` dans un `node -e`) avant de merger, un `pnpm build`
|
||
> qui passe ne suffit pas à détecter un crash runtime.
|
||
|
||
Les tests (Mocha) tournent avec un coût argon2 réduit
|
||
(`NODE_ENV=test`, voir `auth.service.ts`) — le coût par défaut est volontairement
|
||
élevé (sécurité), ce qui rendrait la suite de tests lente/instable sinon. La CI
|
||
provisionne un vrai Postgres de service (`.github/workflows/ci.yml`) et exécute
|
||
`prisma migrate deploy` avant les tests.
|
||
|
||
## Foyer — création, invitation, admin, sources externes (apps/api)
|
||
|
||
Un foyer (`house`) a un admin (`adminId`) et un code d'invitation à 8
|
||
caractères (`inviteCode`, alphabet sans caractères ambigus `0`/`O`/`1`/`I`).
|
||
|
||
- `GET`/`PATCH /house/current` — foyer courant. `PATCH { name }` ouvert à tout membre.
|
||
- `POST /house` — crée un foyer (l'appelant devient admin) ; `POST /house/join
|
||
{ inviteCode }` — rejoint un foyer existant. Les deux 409 `ALREADY_HAS_HOUSE`
|
||
si le profil a déjà un foyer.
|
||
- `POST /house/leave` — quitte le foyer courant. Si le partant était l'admin,
|
||
l'adminship passe au membre restant le plus ancien ; si plus personne ne
|
||
reste, le foyer est supprimé (un foyer ne peut jamais rester sans admin).
|
||
- `DELETE /house/current` — supprime le foyer (403 `NOT_HOUSE_ADMIN` si appelé
|
||
par un non-admin). `DELETE /house/members/:id` — retire un membre (admin
|
||
seulement, pas de self-retrait par cette route, utiliser `/leave`).
|
||
- `GET`/`PATCH /house/current/sources` — quelles sources externes de recettes
|
||
(voir plus bas) le foyer voit dans son catalogue — `{ sourceIds: number[] }`,
|
||
remplace (pas de fusion), opt-in (aucune source activée par défaut).
|
||
|
||
Détail complet (génération du code, transfert d'adminship) :
|
||
[specs/backend-architecture.md](specs/backend-architecture.md#house--foyer-adminship-code-dinvitation-sources-activées).
|
||
|
||
## Planning (apps/api)
|
||
|
||
- `GET /planning?date=YYYY-MM-DD` — planning de la semaine (lundi→dimanche)
|
||
couvrant `date`, pour le foyer de l'utilisateur connecté — `PlanningView |
|
||
null` (`null` = pas de foyer, ou aucun planning pour cette semaine, deux cas
|
||
normaux confondus, jamais une erreur).
|
||
- `POST /planning/items` — ajoute une recette à un créneau :
|
||
`{ date, weekDay, meal, recipeId, portions }`. `portions` est saisi
|
||
indépendamment du rendement propre de la recette (`Recipe.portions`) — un
|
||
créneau peut mettre à l'échelle.
|
||
- `DELETE /planning/items/:id` — retire un item du planning.
|
||
|
||
Le planning d'une semaine est créé à la demande (première recette ajoutée),
|
||
jamais en avance.
|
||
|
||
## Recettes — catalogue, favoris, import depuis une source externe (apps/api)
|
||
|
||
- `GET /recipes?tab=favoris|perso|foyer|publique&search=&suitableForHousehold=&ingredientIds=&dietIds=`
|
||
— catalogue filtré par onglet + filtres optionnels. `PERSONAL`/`HOUSE`/`PUBLIC`
|
||
(`Recipe.visibility`) contrôlent qui peut **lire** une recette (jamais qui
|
||
peut l'éditer, toujours réservé à l'auteur) ; les recettes issues d'une
|
||
source externe non activée pour le foyer du viewer sont masquées de tous les
|
||
onglets.
|
||
- `GET /recipes/:id`, `POST /recipes`, `PATCH /recipes/:id`,
|
||
`DELETE /recipes/:id` (409 `RECIPE_IN_USE` si encore référencée par un
|
||
planning), `POST`/`DELETE /recipes/:id/favorite`.
|
||
- **Import depuis une source externe** (`/sources`) : `GET
|
||
/sources/:sourceKey/browse` (parcourir), `GET
|
||
/sources/:sourceKey/preview/:externalId` (prévisualiser sans sauvegarder —
|
||
ingrédients/unités/techniques déjà résolus contre les catalogues), `POST
|
||
/sources/:sourceKey/import/:externalId` (finaliser — même payload qu'une
|
||
création manuelle). Un item de source n'est sauvegardé qu'en conséquence de
|
||
son ajout au planning (import transparent si tout est résolu) ou d'une revue
|
||
manuelle (ingrédients ambigus à choisir à la main) — jamais un bouton
|
||
"importer" isolé. Une seule source concrète aujourd'hui : **TheMealDB**
|
||
(API officielle, catalogue anglais).
|
||
|
||
Détail complet (adaptateurs, algorithmes de matching ingrédients/techniques,
|
||
synchronisation de la table `sources`) :
|
||
[specs/backend-architecture.md](specs/backend-architecture.md#sources-externes--adaptateur-registre-synchronisation).
|
||
|
||
## Données de référence — régimes, allergènes, ingrédients, unités, techniques (apps/api)
|
||
|
||
- `GET /reference/diets`, `/allergies`, `/ingredients`, `/units`,
|
||
`/tech-steps`, `/sources` — tous **publics** (pas de `requireAuth`) : ce sont
|
||
des données de référence, pas des données de foyer, et le wizard
|
||
d'inscription doit pouvoir les lire avant qu'un compte n'existe.
|
||
|
||
Données seedées via `apps/api/src/db/reference-seed-data.ts` (`pnpm --filter
|
||
api prisma:seed`, ou automatiquement après `prisma migrate reset`) — jamais
|
||
créées/éditées/supprimées via l'API applicative. `key`/`name` sont `@unique`
|
||
pour permettre un seed idempotent (`upsert`). Le catalogue d'ingrédients
|
||
(400+) est organisé en 7 rayons/sous-catégories façon supermarché français, et
|
||
chaque allergène est classé `ALLERGY` (immunitaire) ou `INTOLERANCE`
|
||
(Gluten/Sulfites). Détail complet du schéma :
|
||
[specs/batch-cooking-modele.md](specs/batch-cooking-modele.md).
|
||
|
||
## Foyer & profil — régime, allergènes, ingrédients détestés (apps/api)
|
||
|
||
Nécessitent tous une session (`requireAuth`) — données propres à
|
||
l'utilisateur/au foyer, pas des données de référence.
|
||
|
||
- `PATCH /profile/diet { dietId: number | null }` — régime du profil connecté ;
|
||
`null` efface le régime.
|
||
- `GET`/`PATCH /profile/allergies` — allergènes/intolérances (medical), liste
|
||
d'IDs, remplace (pas de fusion).
|
||
- `GET`/`PATCH /profile/disliked-ingredients` — ingrédients personnellement
|
||
"pas aimés" (**goût, pas médical** — ne déclenche jamais un avertissement de
|
||
sécurité, juste un rappel discret sur la fiche recette), même contrat de
|
||
remplacement.
|
||
- `GET`/`PATCH /preferences { theme: "LIGHT"|"DARK"|"SYSTEM" }` — préférence
|
||
d'affichage, upsert (pas de ligne tant que rien n'a été choisi, défaut
|
||
`SYSTEM`).
|
||
|
||
`apps/api/src/lib/safe-profile.ts` centralise le retrait du `passwordHash`
|
||
(`toSafeProfile`).
|
||
|
||
## Page de connexion / inscription (apps/web)
|
||
|
||
- `src/api/client.ts` — `ApiClient` (classe, instance unique exportée
|
||
`apiClient`) : enveloppe `fetch` vers l'API (`credentials: "include"`, requis
|
||
pour que le cookie de session httpOnly parte/revienne — l'API et le front
|
||
sont sur des origines différentes). URL configurable via `VITE_API_URL`
|
||
(voir `.env.example`).
|
||
- `src/features/auth/AuthContext.tsx` — état d'auth global ; appelle `GET
|
||
/auth/me` au chargement pour restaurer la session depuis le cookie ;
|
||
`deleteAccount()` pour la suppression de compte.
|
||
- `src/features/auth/RequireAuth.tsx` / `RedirectIfAuthenticated.tsx` — gardes
|
||
de route (react-router-dom) : l'espace connecté exige d'être connecté,
|
||
`/login` et `/signup` redirigent vers `/` si on l'est déjà.
|
||
- `src/pages/{Login,Signup}Page.tsx` — validation client instantanée via les
|
||
schémas zod partagés (`packages/shared`), erreurs API traduites via
|
||
`ErrorMessageService` (voir ci-dessous).
|
||
|
||
Détail de l'organisation complète (dossiers, routing, SCSS/theming) :
|
||
[specs/frontend-architecture.md](specs/frontend-architecture.md).
|
||
|
||
## Sidebar, planning, recettes & sections (apps/web)
|
||
|
||
Une fois connecté, l'utilisateur atterrit sur `src/layouts/AppLayout.tsx` —
|
||
sidebar (nav Planning/Recettes/Liste de courses, sous-menu Paramètres
|
||
repliable, menu compte en pied) et `<Outlet />` pour la route active — montée
|
||
une seule fois comme route parente de tout l'espace authentifié (`App.tsx`).
|
||
|
||
- **`/` — `PlanningPage`** : grille complète de la semaine (7 jours × 5
|
||
repas), navigation par semaine avec mini-calendrier, ajout via
|
||
`RecipePickerDialog` (parcourir le catalogue **et** les sources externes,
|
||
prévisualiser avant de confirmer, import transparent en un clic si la
|
||
recette d'une source n'est pas encore résolue automatiquement, sinon revue
|
||
intégrée dans le même dialogue).
|
||
- **`/recettes`** (+ `/recettes/:id`, `/recettes/sources/:sourceKey/:externalId`)
|
||
— `RecipesPage`, vue maître-détail : onglets favoris/perso/foyer/publique
|
||
**plus un onglet par source externe activée pour le foyer**, tableau +
|
||
panneau de détail (surlignage des techniques détectées avec infobulle, icônes
|
||
d'ingrédients génériques, badges régime/allergènes/reproductible).
|
||
`/recettes/nouvelle` et `/recettes/:id/modifier` (`RecipeFormPage`) pour la
|
||
création/édition manuelle.
|
||
- **`/liste-de-courses`** — toujours un stub (`ComingSoonPage`), le module
|
||
« Calcul batch-cooking » reste `TODO` (voir
|
||
[specs/batch-cooking-architecture.md](specs/batch-cooking-architecture.md)).
|
||
- **`/parametres/*`** — Compte (identité + suppression), Préférences
|
||
(régime/allergies/ingrédients détestés), Foyer (création/invitation,
|
||
membres, sources activées), Préférences utilisateur (thème
|
||
clair/sombre/système), Crédits (attribution des icônes CC BY 4.0).
|
||
|
||
Détail complet (pourquoi une seule route parente, le flux d'import détaillé,
|
||
les composants UI partagés `Dialog`/`Checkbox`/`Radio`/`Tooltip`) :
|
||
[specs/frontend-architecture.md](specs/frontend-architecture.md).
|
||
|
||
## Parcours d'inscription — onboarding (apps/web)
|
||
|
||
Wizard de 4 écrans lancé une fois juste après l'inscription :
|
||
`/onboarding/regime` → `/onboarding/foyer` → `/onboarding/sources`
|
||
(conditionnelle, sautée si aucun foyer n'a été créé/rejoint à l'étape
|
||
précédente) → `/onboarding/allergenes`. Chaque étape a un unique bouton
|
||
"Continuer" qui envoie la valeur courante (y compris "aucune" pour
|
||
régime/allergènes) — pas de bouton "Passer" séparé, skip implicite. Routes
|
||
top-level `RequireAuth`, **pas** nichées sous `AppLayout` : wizard plein écran
|
||
sans sidebar, même langage visuel que `/login`/`/signup`. Les mêmes réglages
|
||
restent modifiables à tout moment depuis `/parametres/*` (hot saving, pas de
|
||
bouton "Enregistrer" — chaque champ sauvegarde peu après la dernière
|
||
modification).
|
||
|
||
Tests Cypress (`apps/web/cypress/e2e/*.cy.ts` et `*.feature` +
|
||
`@badeball/cypress-cucumber-preprocessor`) : mockent l'API via `cy.intercept`
|
||
plutôt que de dépendre d'un vrai backend — le job e2e de la CI ne provisionne
|
||
pas de Postgres/API, seulement le serveur de dev Vite. Le comportement réel de
|
||
l'API est couvert par la suite Mocha d'`apps/api` (contre une vraie base).
|
||
Détail du dispositif de test (Gherkin + steps partagés, tests de composant) :
|
||
[specs/frontend-architecture.md](specs/frontend-architecture.md#tests-cypress--cucumber).
|
||
|
||
> **Cypress ne peut pas tourner en local dans un environnement Windows sandboxé** :
|
||
> Chromium/Electron headless plante au lancement du process GPU
|
||
> (`GPU process isn't usable`), reproductible sur `main` aussi bien que sur une
|
||
> branche de feature — pas un problème introduit par une modification du code.
|
||
> `pnpm --filter web e2e` fonctionne normalement en CI (GitHub Actions) et sur une
|
||
> machine de dev classique ; dans cet environnement précis, vérifier manuellement via
|
||
> le serveur de dev (`pnpm dev:web` + `pnpm dev:api` en local, pas le conteneur
|
||
> Docker — voir [Déploiement](#déploiement) — qui sert le frontend buildé, pas le
|
||
> serveur de dev Vite).
|
||
|
||
## Gestion des erreurs (API ↔ web)
|
||
|
||
Contrat d'erreurs partagé via `packages/shared` (`ErrorCode`, énumération
|
||
**numérique** groupée par famille — `4000` validation, `401x` auth, `402x`
|
||
conflit/état invalide, `403x` autorisation, `404x` not found, `500x` interne
|
||
— et `ApiErrorResponse`) : l'API renvoie toujours
|
||
`{ code, message, details? }` (message en anglais, dev-facing — jamais affiché tel
|
||
quel), et le client traduit `code` en libellé français via **i18next**
|
||
(`ErrorMessageService`, `apps/web/src/services/error-message.service.ts` →
|
||
`apps/web/src/locales/fr/translation.json`). Côté API, `ErrorHandlerService`
|
||
(`packages/error-tools`) et `createErrorMiddleware` (`packages/express-tools`)
|
||
centralisent la transformation de toute erreur levée en réponse HTTP conforme —
|
||
aucune valeur `ErrorCode` codée en dur nulle part (toujours `ErrorCode.XXX`, y
|
||
compris dans les mocks Cypress).
|
||
|
||
Détail complet (schéma, liste des ~19 codes actuels, exemples, comment ajouter
|
||
un nouveau code d'erreur) : [specs/error-handling.md](specs/error-handling.md).
|
||
|
||
Le profil authentifié (`requireAuth`) passe par `res.locals.userProfile`
|
||
(typé via `AuthLocals`), pas par une augmentation du namespace global Express —
|
||
voir [specs/backend-architecture.md](specs/backend-architecture.md) pour le détail
|
||
et le pourquoi.
|
||
|
||
`packages/shared` fournit aussi `assertIsNever` (vérification d'exhaustivité de
|
||
switch/if-chain sur une union, erreur de **compilation** si un cas est oublié) —
|
||
voir [specs/backend-architecture.md](specs/backend-architecture.md#packagesshared--assertisnever).
|
||
|
||
## i18n
|
||
|
||
**i18next** + **react-i18next** — tout le texte affiché (formulaires, boutons,
|
||
erreurs) vient de fichiers de locale JSON (`apps/web/src/locales/<lng>/translation.json`),
|
||
jamais codé en dur dans un composant. Une seule langue existe aujourd'hui (`fr`) ;
|
||
en ajouter une est une question de fichier de locale, pas de code. Les
|
||
libellés des tables de référence (régimes, allergènes, ingrédients, unités,
|
||
techniques) vivent aussi dans ce fichier (`catalog.*`, par `key` stable de
|
||
`schema.prisma`), jamais stockés en base. Détail :
|
||
[specs/frontend-architecture.md](specs/frontend-architecture.md#i18n-internationalisation).
|
||
|
||
## Thème clair / sombre / système (apps/web)
|
||
|
||
Préférence par utilisateur, persistée côté serveur (`GET`/`PATCH
|
||
/preferences`, pas `localStorage`). Tokens de design en custom properties CSS
|
||
(`apps/web/src/styles/_theme.scss`) redéfinies sous `[data-theme="dark"]`
|
||
(choix explicite) ou sous `prefers-color-scheme: dark` quand aucun
|
||
`data-theme` n'est posé (choix "système", le défaut). Détail :
|
||
[specs/frontend-architecture.md](specs/frontend-architecture.md#thème-clairsombresystème).
|
||
|
||
## Données de test (faker.js)
|
||
|
||
`apps/api` utilise [`@faker-js/faker`](https://fakerjs.dev/) pour toutes les données
|
||
de test dans `test/*.test.ts` (Mocha) — jamais de nom/email qui ressemble à une
|
||
vraie personne en dur dans un fixture.
|