* 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>
651 lines
36 KiB
Markdown
651 lines
36 KiB
Markdown
# Architecture backend — Projet Batch-cooking
|
||
|
||
> Documentation de l'organisation d'`apps/api` et de l'outillage partagé
|
||
> (`packages/express-tools`, `packages/error-tools`, `packages/shared`).
|
||
|
||
---
|
||
|
||
## `packages/express-tools` — outillage Express générique
|
||
|
||
Package séparé, réutilisable par n'importe quel service Express du monorepo (pas
|
||
seulement `apps/api`) : pas de logique métier, juste de l'infra Express.
|
||
|
||
### `ExpressServer` — init serveur, routes, middlewares
|
||
|
||
Enveloppe une application Express derrière une API typée, au lieu que chaque
|
||
service refasse le même `express()` à la main :
|
||
|
||
```ts
|
||
const server = new ExpressServer();
|
||
server.setupCore({ corsOrigin: env.CORS_ORIGIN }); // cors + json + cookie-parser
|
||
server.addRoute("get", "/health", (_req, res) => res.status(200).json({ status: "ok" }));
|
||
server.mountRouter("/auth", authRouter);
|
||
server.addMiddleware(notFoundHandler);
|
||
server.setErrorHandler(createErrorMiddleware(errorHandlerService));
|
||
server.listen(port, () => console.log(`Listening on ${port}`));
|
||
```
|
||
|
||
- `setupCore(options)` — middleware stack commun (CORS avec credentials, JSON,
|
||
cookies).
|
||
- `addRoute(method, path, ...handlers)` — enregistre une route ; avertit et
|
||
ignore au lieu d'écraser silencieusement si la même route (méthode + chemin)
|
||
est déjà enregistrée.
|
||
- `addMiddleware` / `mountRouter` / `setErrorHandler` — ajout de middleware
|
||
générique, montage d'un `Router` complet, middleware d'erreur final (4
|
||
arguments — doit être ajouté en dernier).
|
||
- `.instance` — l'app Express brute, nécessaire pour les outils de test
|
||
(supertest) qui attendent une instance `Express`, pas le wrapper.
|
||
- `.listen(port, onListening?)` — démarre le serveur.
|
||
|
||
`apps/api/src/app.ts` expose deux fonctions : `createServer(): ExpressServer`
|
||
(utilisée par `server.ts`, qui appelle `.listen()`) et `createApp(): Express`
|
||
(= `createServer().instance`, utilisée par les tests).
|
||
|
||
### `wrapAsyncHandler` — plus de try/catch répété dans les routes
|
||
|
||
```ts
|
||
router.post("/signup", wrapAsyncHandler(async (req, res) => {
|
||
const profile = await signup(req.body); // une erreur/rejet ici va automatiquement à next()
|
||
res.status(201).json(profile);
|
||
}));
|
||
```
|
||
|
||
Sans ça, une exception dans un handler `async` ne remonte jamais tout seule au
|
||
middleware d'erreur d'Express — chaque route devait faire son propre
|
||
`try { ... } catch (err) { next(err); }`. `wrapAsyncHandler` l'automatise.
|
||
|
||
### `AsyncRequestHandler`/`wrapAsyncHandler` — `Locals` contraint par `Record<string, any>`, pas `unknown`
|
||
|
||
Le paramètre générique `Locals` est contraint par `Record<string, any>`, à
|
||
l'identique du propre `Response<ResBody, LocalsObj>` d'Express
|
||
(`@types/express-serve-static-core`) — volontairement, pas `Record<string,
|
||
unknown>` (plus strict, ce qui serait la contrainte "par défaut" attendue).
|
||
Raison concrète : une `interface` sans signature d'index (ex. `AuthLocals`
|
||
dans `require-auth.ts`) échoue la contrainte générique sous `unknown` alors
|
||
qu'elle s'assigne très bien à `Response`'s own `Locals` param directement —
|
||
observé en committant `wrapAsyncHandler<unknown, AuthLocals>(...)` sur ce qui
|
||
était alors `GET /planning/current` (premier endpoint à combiner
|
||
authentification et handler async — la route a depuis évolué vers
|
||
`GET /planning?date=`, voir plus bas, mais la contrainte générique qu'elle a
|
||
mise au jour n'a pas bougé). `any` referme cet écart structurel ; les deux occurrences
|
||
portent un commentaire `biome-ignore lint/suspicious/noExplicitAny` expliquant
|
||
pourquoi (le lint interdit `any` par défaut, à raison, mais ce cas précis
|
||
imite un type de la lib standard Express qui fait le même choix).
|
||
|
||
### `createErrorMiddleware` — adaptateur Express pour `packages/error-tools`
|
||
|
||
Voir [error-handling.md](./error-handling.md) pour le détail. `HttpError` et
|
||
`ErrorHandlerService` vivent dans **`packages/error-tools`**, pas ici :
|
||
`ErrorHandlerService` **n'a aucune dépendance à Express** — c'est un service
|
||
générique `erreur → { status, body }` qui fonctionnerait à l'identique derrière
|
||
Fastify ou n'importe quel autre framework, donc il n'a rien à faire dans un
|
||
package *express*-tools. `ExpressServer` et `createErrorMiddleware` (ici) sont
|
||
la vraie couche Express : elles adaptent des pièces indépendantes du framework
|
||
(`ErrorHandlerService`, importé depuis `@batch-cooking/error-tools`) à l'API
|
||
d'Express.
|
||
|
||
---
|
||
|
||
## Auth : `res.locals`, pas d'augmentation du namespace Express
|
||
|
||
`requireAuth` (`apps/api/src/middlewares/require-auth.ts`) attache le profil
|
||
authentifié à **`res.locals.userProfile`**, typé via l'interface `AuthLocals` :
|
||
|
||
```ts
|
||
export interface AuthLocals {
|
||
userProfile: SafeUserProfile;
|
||
}
|
||
|
||
export async function requireAuth(req: Request, res: Response<unknown, AuthLocals>, next: NextFunction) {
|
||
// ...
|
||
res.locals.userProfile = safeProfile;
|
||
next();
|
||
}
|
||
```
|
||
|
||
Un handler derrière ce middleware type sa réponse `Response<unknown, AuthLocals>`
|
||
et lit `res.locals.userProfile` sans cast :
|
||
|
||
```ts
|
||
authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => {
|
||
res.status(200).json(res.locals.userProfile);
|
||
});
|
||
```
|
||
|
||
**Pourquoi pas `declare global { namespace Express { interface Request {...} } }`**
|
||
(l'approche initialement utilisée, retirée depuis) : `res.locals` est le
|
||
mécanisme natif d'Express prévu exactement pour ça (faire passer des données
|
||
d'un middleware au handler suivant), typé par route via un paramètre
|
||
générique — pas une augmentation globale et permanente qui change
|
||
silencieusement le type de **toutes** les `Request` du projet, qu'elles soient
|
||
passées par ce middleware ou non.
|
||
|
||
---
|
||
|
||
## `packages/shared` — `assertIsNever`
|
||
|
||
`packages/shared/src/tools/assert-is-never.ts` — vérification d'exhaustivité
|
||
pour un `switch`/`if`-chain sur une union :
|
||
|
||
```ts
|
||
switch (shape.kind) {
|
||
case "circle": return Math.PI * shape.radius ** 2;
|
||
case "square": return shape.side ** 2;
|
||
default: return assertIsNever(shape); // erreur de compilation si un cas manque
|
||
}
|
||
```
|
||
|
||
Si un membre de l'union n'est pas traité par une branche précédente, `shape`
|
||
n'est plus de type `never` au niveau du `default` → **erreur de compilation**
|
||
(vérifié : `tsc` rejette bien un cas manquant). Lève aussi une vraie erreur au
|
||
runtime, en filet de sécurité si une valeur invalide échappe au système de
|
||
types (ex. donnée externe non validée).
|
||
|
||
Pas encore de point d'usage réel dans le code métier actuel (aucun
|
||
switch/if-chain exhaustif sur une union n'existe encore) — prêt à l'emploi dès
|
||
qu'un cas s'y prête (le module « Calcul batch-cooking » ou le pipeline d'import
|
||
de recette, tous deux encore à construire, en auront probablement).
|
||
|
||
---
|
||
|
||
## `house` — foyer, adminship, code d'invitation, sources activées
|
||
|
||
Router `/house` (`apps/api/src/modules/house/house.routes.ts` +
|
||
`house.service.ts`), toutes les routes derrière `requireAuth`.
|
||
|
||
| Route | Fonction | Détail |
|
||
|---|---|---|
|
||
| `GET /house/current` | `getCurrentHouse` | `HouseView \| null` |
|
||
| `PATCH /house/current` | `renameHouse` | `{ name }`, ouvert à **tout membre**, pas seulement l'admin |
|
||
| `POST /house/` | `createHouse` | 201, crée le foyer avec l'appelant comme `adminId`, génère le code d'invitation |
|
||
| `POST /house/join` | `joinHouse` | `{ inviteCode }` (8 caractères exactement) |
|
||
| `POST /house/leave` | `leaveCurrentHouse` | 204 |
|
||
| `DELETE /house/current` | `deleteHouse` | 204, réservé à l'admin |
|
||
| `GET /house/current/sources` | `getHouseSourceIds` | `number[]` d'ids `Source` activés |
|
||
| `PATCH /house/current/sources` | `updateHouseSources` | `{ sourceIds: number[] }`, remplace (pas de fusion) |
|
||
| `DELETE /house/members/:memberId` | `removeMember` | réservé à l'admin, ne peut pas cibler soi-même |
|
||
|
||
**Génération du code d'invitation** — `generateInviteCode()` tire 8 caractères
|
||
dans `INVITE_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"` : majuscules +
|
||
chiffres, **sans** les caractères visuellement ambigus (`0`/`O`/`1`/`I`) — pensé
|
||
pour être lu sur un écran et retapé sur un autre. Les collisions ne sont pas
|
||
pré-vérifiées (33⁸ possibilités, astronomiquement improbable) mais gérées par
|
||
réessai (jusqu'à 5 tentatives) sur la violation de contrainte unique Postgres
|
||
(`P2002`) plutôt que supposées impossibles.
|
||
|
||
**Départ et transfert d'adminship** (`leaveCurrentHouse`) — si le membre qui
|
||
part est l'admin, l'adminship est transférée au membre restant le plus ancien
|
||
(id le plus petit) ; s'il ne reste personne, le foyer est supprimé
|
||
(plannings en cascade). **Un foyer ne peut jamais rester sans admin.** Cette
|
||
fonction est aussi appelée par `auth.service.ts`'s `deleteAccount` avant la
|
||
suppression du profil.
|
||
|
||
**Sources activées** (`HouseSource`, table de jointure `houseId`/`sourceId`) —
|
||
`getHouseSourceIds`/`updateHouseSources` en gèrent le contenu. **Aucune ligne
|
||
au départ pour un nouveau foyer** — opt-in, pas "aucune préférence exprimée".
|
||
`recipe.service.ts`'s `listRecipes` filtre chaque onglet du catalogue contre cet
|
||
ensemble (voir plus bas). Détail complet du flux de sources :
|
||
[batch-cooking-architecture.md](./batch-cooking-architecture.md), section "Module « Import d'une recette »".
|
||
|
||
**Codes d'erreur** : `HOUSE_NOT_FOUND` (4041), `ALREADY_HAS_HOUSE` (4020),
|
||
`INVITE_CODE_NOT_FOUND` (4044), `NOT_HOUSE_ADMIN` (4030), `SOURCE_NOT_FOUND`
|
||
(4049, `sourceId` inconnu dans `updateHouseSources`).
|
||
|
||
Note : si le `houseId` d'un profil pointe vers un foyer qui n'existe plus (état
|
||
interne incohérent), l'échec du lookup interne lève une `Error` brute (→ 500),
|
||
volontairement **pas** une `HttpError` — ce cas signale une incohérence
|
||
interne, pas un "not found" normal qu'un client pourrait déclencher.
|
||
|
||
---
|
||
|
||
## `preferences` (thème) et `/profile/disliked-ingredients` (goûts)
|
||
|
||
**`preferences`** (`preferences.routes.ts`/`.service.ts`, `/preferences`,
|
||
`requireAuth`) : `GET /preferences` → `{ theme }` (défaut `"SYSTEM"` si aucune
|
||
ligne `UserPreference` n'existe encore — pas de création à la volée pour un
|
||
simple `GET`) ; `PATCH /preferences` → `{ theme: "LIGHT"|"DARK"|"SYSTEM" }`,
|
||
**upsert** de `UserPreference` (`userProfileId` est à la fois clé primaire et
|
||
étrangère, 1-1 strict avec `UserProfile`).
|
||
|
||
**Ingrédients détestés** vivent sous `/profile`, **pas** `/preferences` :
|
||
`GET`/`PATCH /profile/disliked-ingredients` (`profile.routes.ts`), remplace
|
||
(pas de fusion), chaque id validé contre `Ingredient` (404
|
||
`INGREDIENT_NOT_FOUND` sinon). Explicitement distinct de `GET`/`PATCH
|
||
/profile/allergies` : une préférence de **goût**, jamais un avertissement de
|
||
sécurité — voir la note sur `UserProfileDislikedIngredient` dans
|
||
[batch-cooking-modele.md](./batch-cooking-modele.md#ingredients-ingredient-et-catalogue-associé).
|
||
Géré depuis `PreferencesPage` côté web (`/parametres/preferences`).
|
||
|
||
---
|
||
|
||
## `planning` — semaine, item, portions, import à la volée
|
||
|
||
Router `/planning` (`planning.routes.ts`/`.service.ts`), `requireAuth`.
|
||
|
||
- `GET /planning?date=YYYY-MM-DD` → `getPlanningForDate` → `PlanningView |
|
||
null`. `null` recouvre **deux** états normaux confondus : pas de foyer, ou
|
||
aucun `Planning` ne couvre cette date — jamais une erreur.
|
||
- `POST /planning/items` → `addPlanningItem`, 201. Body `addPlanningItemSchema`
|
||
= `{ date, weekDay, meal, recipeId, portions }` (`weekDay`/`meal` sont des
|
||
enums `WEEK_DAYS`/`MEALS` de `packages/shared`, réellement validés ici — pas
|
||
juste une convention documentée). `recipeId` doit exister et être visible par
|
||
l'appelant (`assertRecipeVisible`, `recipe.service.ts`) → 404
|
||
`RECIPE_NOT_FOUND` sinon.
|
||
- `DELETE /planning/items/:id` → `removePlanningItem`, 204.
|
||
|
||
**`PlanningItem.portions`** est saisi **indépendamment** de `Recipe.portions`
|
||
(le rendement "tel qu'écrit" de la recette) — un créneau peut mettre à
|
||
l'échelle. Le picker web pré-remplit depuis `Recipe.portions` mais envoie
|
||
toujours sa propre valeur.
|
||
|
||
**Création de la semaine** — `findOrCreatePlanningForWeek` est le seul
|
||
endroit qui crée une ligne `Planning`, retrouvée par `startDate` exact (un
|
||
lundi, via `@batch-cooking/date-tools`'s `getWeekStart`), lundi→dimanche.
|
||
Lookup et création **ne sont pas transactionnels** ensemble — pas de contrainte
|
||
unique `(houseId, startDate)` — une course pourrait donc créer deux lignes pour
|
||
la même semaine vide ; accepté à l'échelle actuelle du projet plutôt que
|
||
d'ajouter une migration + boucle retry-on-conflict.
|
||
|
||
**"Ajouter au planning déclenche l'import si besoin"** — c'est une
|
||
**orchestration côté frontend**, pas une fonctionnalité backend combinée : il
|
||
n'existe aucune route "importer + ajouter au planning" en un seul appel. Le
|
||
web (`RecipePickerDialog.tsx`) appelle simplement `POST
|
||
/sources/:sourceKey/import/:externalId` (voir plus bas) puis `POST
|
||
/planning/items` l'un après l'autre — les deux routes existaient déjà et se
|
||
suffisent à elles-mêmes, aucun changement backend n'a été nécessaire pour cette
|
||
feature. Détail du flux complet :
|
||
[batch-cooking-architecture.md](./batch-cooking-architecture.md), section "Module « Import d'une recette »".
|
||
|
||
---
|
||
|
||
## Liste de courses — agrégation des ingrédients planifiés
|
||
|
||
Router `/shopping-list` (`shopping-list.routes.ts`/`.service.ts`),
|
||
`requireAuth` — un seul endpoint : `GET /shopping-list?date=YYYY-MM-DD` →
|
||
`getShoppingListForDate` → `ShoppingListView`. Même contrat `?date=` que
|
||
`GET /planning` (même schéma de requête shape-only, validation calendaire
|
||
réelle via `date-tools`'s `parseDateOnly`), même requête "plage couvrante"
|
||
(`startDate <= date <= finishDate`) que `getPlanningForDate` — mais
|
||
**jamais `null`** : pas de foyer, ou aucun `Planning` ne couvre la semaine,
|
||
retombent tous deux sur un `ShoppingListView` normal à `items: []` plutôt
|
||
qu'un état à part que le frontend devrait distinguer.
|
||
|
||
**Agrégation** (`aggregateShoppingList`, pure/synchrone — testable sans base)
|
||
— pour chaque `PlanningItem` de la semaine, chaque ligne
|
||
`RecipeIngredient` de sa recette est mise à l'échelle
|
||
(`quantity × PlanningItem.portions / Recipe.portions`, cf.
|
||
`PlanningItem.portions`'s doc comment dans schema.prisma) puis sommée dans
|
||
une `Map` clée par **`(ingredientId, unitId)`** — pas juste `ingredientId` :
|
||
la même ligne d'ingrédient dans deux unités différentes (ex. une recette en
|
||
grammes, une autre en kilogrammes pour le même ingrédient) reste deux lignes
|
||
séparées, aucune conversion inter-unités n'étant construite (voir
|
||
`UnitView.toBaseFactor`'s doc comment, `packages/shared`). L'ordre final
|
||
(par `Ingredient.key`) n'est là que pour un JSON déterministe en test — le
|
||
frontend retrie par rayon/libellé traduit pour l'affichage (voir
|
||
[frontend-architecture.md](./frontend-architecture.md), section "Liste de
|
||
courses").
|
||
|
||
`shopping-list.service.ts` réutilise directement `recipe.service.ts`'s
|
||
`toIngredientView`/`toUnitView` (exportées pour cette raison) plutôt que de
|
||
re-dupliquer le même mapping Prisma → vue publique — sa propre requête
|
||
Prisma ne charge qu'un sous-ensemble de `Recipe` (juste `portions` +
|
||
`ingredients`, pas `steps`/`diets`/`favoritedBy`) mais avec exactement la
|
||
même forme imbriquée `ingredient.allergies`/`ingredient.diets` que
|
||
`recipe.service.ts`'s `recipeInclude`, donc les deux fonctions s'appliquent
|
||
telles quelles par typage structurel.
|
||
|
||
---
|
||
|
||
## `reference` — catalogues publics (pas de session requise)
|
||
|
||
Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les
|
||
routes sont publiques**, 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'une session n'existe.
|
||
|
||
| Route | Contenu |
|
||
|---|---|
|
||
| `GET /reference/diets` | régimes alimentaires, triés par `key` |
|
||
| `GET /reference/allergies` | allergènes/intolérances (`Allergy` → `Category{key, kind}`) |
|
||
| `GET /reference/ingredients` | catalogue d'ingrédients, avec `allergens[]`/`diets[]` résolus |
|
||
| `GET /reference/units` | unités de mesure, `toBaseFactor` (Decimal → number) |
|
||
| `GET /reference/tech-steps` | techniques (pas encore consommé par l'UI recette elle-même — groundwork) |
|
||
| `GET /reference/sources` | sources d'import enregistrées, triées par **`name`** (pas `key` — c'est le vrai libellé affiché, un nom propre, pas une clé à traduire) |
|
||
|
||
Toutes seedées via `apps/api/src/db/reference-seed-data.ts` (voir le README
|
||
pour la commande de seed) — jamais créées/éditées/supprimées via l'API
|
||
applicative.
|
||
|
||
---
|
||
|
||
## Sources externes — adaptateur, registre, synchronisation
|
||
|
||
Le module « Import d'une recette » du plan initial (voir
|
||
[batch-cooking-architecture.md](./batch-cooking-architecture.md)) est
|
||
implémenté. Pièces principales :
|
||
|
||
### `RecipeSourceAdapter` (`apps/api/src/lib/recipe-sources/recipe-source-adapter.ts`)
|
||
|
||
Contrat générique que chaque source concrète implémente : `list(params)`
|
||
(parcours paginé, `query`/`cursor` optionnels), `fetchDetail(externalId)`
|
||
(contenu brut d'un item), `parse(raw)` (pur, synchrone, testable sans réseau —
|
||
transforme le brut en `ParsedRecipe` normalisé : ingrédients/étapes en texte
|
||
libre, pas encore résolus contre les catalogues). `official` (API officielle
|
||
vs scraping non-officiel) et `locale` (langue du contenu produit par la
|
||
source, pas une préférence utilisateur) n'ont pas de valeur par défaut —
|
||
chaque auteur d'adaptateur doit choisir consciemment. `markAlreadyImported`
|
||
annote une page de résultats en comparant les `externalId` à un ensemble déjà
|
||
importé — étape pure et séparée, l'adaptateur ne connaît jamais la base de
|
||
données.
|
||
|
||
### Registre (`recipe-source-registry.ts`)
|
||
|
||
Map en mémoire `key → adapter`, volontairement **pas** persistée en base — un
|
||
adaptateur *est* du code (la logique de fetch/parse d'un site ne peut pas
|
||
vivre dans une ligne de base). `registerRecipeSource` lève si la clé est déjà
|
||
prise (deux adaptateurs qui s'écraseraient silencieusement serait un bug).
|
||
`clearRecipeSources` n'est utilisée que par les tests, pour l'isolation
|
||
(même rôle que `resetDatabase()` côté base).
|
||
|
||
### Synchronisation (`apps/api/src/db/recipe-source-sync.ts`)
|
||
|
||
`syncRecipeSources(prisma)` upsert une ligne `Source` par adaptateur du
|
||
registre — **ne supprime jamais** une `Source` dont l'adaptateur a disparu du
|
||
registre (une recette déjà importée doit continuer à citer sa source).
|
||
`findImportedRecipeIds(prisma, sourceKey, externalIds)` renvoie une `Map
|
||
<externalId, recipeId>` des items déjà importés (map vide si `sourceKey` n'a
|
||
pas encore de ligne `Source` — jamais une erreur).
|
||
|
||
**Quand ça tourne** :
|
||
- `server.ts` appelle `registerAllRecipeSources()` au démarrage (peuple
|
||
uniquement le registre en mémoire de **ce** processus).
|
||
- `prisma/seed.ts` (dev, `pnpm --filter api prisma:seed` /
|
||
`prisma migrate reset`) enregistre les adaptateurs puis seed + synchronise.
|
||
- `apps/api/src/scripts/seed-runtime.ts` — équivalent pour l'image de
|
||
production, invoqué dans le `CMD` du `Dockerfile` :
|
||
`prisma migrate deploy && node dist/scripts/seed-runtime.js && node
|
||
dist/server.js`. **Nécessaire** car chaque maillon du `CMD` est un
|
||
**processus `node` séparé** : sans cette étape dédiée, le registre peuplé par
|
||
`server.ts` ne touchait jamais la base en production, et `GET
|
||
/reference/sources` renvoyait silencieusement `[]` (toute la section
|
||
"Sources" de `HouseholdSettingsPage` restait invisible) — bug corrigé par le
|
||
commit "synchronise les sources en base au démarrage de l'image de prod".
|
||
Vit sous `src/` (pas `prisma/`) précisément pour être compilé dans `dist` par
|
||
`tsc`, l'image runtime n'embarquant que `dist`, pas `src`.
|
||
- `test-support/reset-db.ts`'s `resetDatabase()` appelle aussi
|
||
`syncRecipeSources` en dernier, après le seed de référence.
|
||
|
||
### Adaptateurs concrets (`apps/api/src/sources/`)
|
||
|
||
- **`the-meal-db.ts`** — `key: "theMealDb"`, `official: true`, `locale: "en"`.
|
||
API publique gratuite (`https://www.themealdb.com/api/json/v1/${API_KEY}`,
|
||
`THE_MEAL_DB_API_KEY` env var, défaut `"1"` = clé de test partagée
|
||
documentée par TheMealDB). `list()` n'a qu'une recherche (`/search.php?s=`),
|
||
pas de vrai "tout parcourir" côté gratuit — une requête vide renvoie un
|
||
petit échantillon fixe (~25 recettes), non paginé (`nextCursor` toujours
|
||
`null`). `parse()` reconstruit les ingrédients depuis les paires plates
|
||
`strIngredient1..20`/`strMeasure1..20`.
|
||
- **`json-ld-recipe.ts`** — `key: "jsonLdRecipe"`, `official: false`,
|
||
scraper générique schema.org/`Recipe` (extraction regex des blocs
|
||
`<script type="application/ld+json">`, gère objet nu / tableau de types
|
||
mixtes / wrapper `@graph`). **Volontairement pas enregistré** dans
|
||
`sources/index.ts` (commit "la source générique JSON-LD n'apparaît plus
|
||
comme source") : c'est un parseur générique pensé pour être spécialisé par
|
||
un futur adaptateur dédié à un site précis, pas une `Source` activable en
|
||
tant que telle — personne ne peut "faire confiance" à un mécanisme de
|
||
parsing générique de la même façon qu'à un site nommé. `list()` renvoie
|
||
toujours vide (pas de catalogue à parcourir) ; `fetchDetail`'s `externalId`
|
||
est directement l'URL cible, pas un id issu d'un `list()` préalable.
|
||
|
||
`apps/api/src/sources/index.ts`'s `registerAllRecipeSources()` n'enregistre
|
||
aujourd'hui que TheMealDB — appelé explicitement par `server.ts`/`seed*`,
|
||
**jamais** par `app.ts` (que chaque test récupère via supertest ; y enregistrer
|
||
un adaptateur réel ferait dépendre sa présence de l'ordre des tests).
|
||
|
||
### Endpoints (`apps/api/src/modules/sources/`, `/sources`, `requireAuth`)
|
||
|
||
| Route | Fonction |
|
||
|---|---|
|
||
| `GET /sources/:sourceKey/browse?query=&cursor=` | `browseSource` — une page du catalogue de la source, chaque item annoté `alreadyImported`/`recipeId` |
|
||
| `GET /sources/:sourceKey/preview/:externalId` | `previewSourceItem` — traduit entièrement un item en `RecipeImportDraftView` **sans le sauvegarder** |
|
||
| `POST /sources/:sourceKey/import/:externalId` | `importSourceItem` — finalise l'import, `input` = un `CreateRecipeInput` normal (mêmes règles qu'une création manuelle) |
|
||
|
||
`sourceKey` doit à la fois exister comme `Source` **activée pour le foyer**
|
||
(`HouseSource`) et avoir un adaptateur toujours enregistré (les deux peuvent
|
||
diverger — voir `syncRecipeSources` plus haut) ; l'un ou l'autre manquant
|
||
ressort en 404 `SOURCE_NOT_FOUND`, sans distinguer les deux cas côté client.
|
||
`previewSourceItem`/`importSourceItem` réutilisent exactement les mêmes
|
||
briques que la sauvegarde normale d'une recette (`translateRecipeIngredients`,
|
||
`matchTechStepSpans` — voir plus bas), la seule différence étant que preview
|
||
ne persiste rien.
|
||
|
||
---
|
||
|
||
## Recettes — visibilité, catalogue, traduction/matching
|
||
|
||
### `recipe` module (`/recipes`, `requireAuth`)
|
||
|
||
| Route | Fonction |
|
||
|---|---|
|
||
| `GET /recipes?tab=&search=&suitableForHousehold=&ingredientIds=&dietIds=` | `listRecipes` |
|
||
| `GET /recipes/:id` | `getRecipe` |
|
||
| `POST /recipes` | `createRecipe` |
|
||
| `PATCH /recipes/:id` | `updateRecipe` (remplacement complet, pas de fusion partielle) |
|
||
| `DELETE /recipes/:id` | `deleteRecipe` (409 `RECIPE_IN_USE` si référencée par un `PlanningItem`) |
|
||
| `POST`/`DELETE /recipes/:id/favorite` | `addFavorite`/`removeFavorite` (idempotents) |
|
||
|
||
**Visibilité** (`RecipeVisibility` — `PERSONAL`/`HOUSE`/`PUBLIC`, voir
|
||
[batch-cooking-modele.md](./batch-cooking-modele.md)) contrôle uniquement la
|
||
**lecture** — l'édition/suppression reste toujours réservée à l'auteur
|
||
(`NOT_RECIPE_AUTHOR`, 403). L'auteur voit toujours sa propre recette, quelle
|
||
que soit sa visibilité actuelle (même une `HOUSE` recipe après avoir quitté ce
|
||
foyer). Un id invisible pour l'appelant ressort en 404, jamais 403 — son
|
||
existence ne doit pas fuiter.
|
||
|
||
**Quatre onglets réels** (`RecipeTab` — `favoris`/`perso`/`foyer`/`publique`,
|
||
pas de `"toutes"` : toute recette visible tombe sous exactement un des trois
|
||
premiers via sa propre `visibility`, `favoris` est un filtre transverse
|
||
orthogonal). Filtres optionnels en plus (`ListRecipesFilters`) : `search`,
|
||
`suitableForHousehold` (recette qui évite tous les allergènes déclarés du
|
||
foyer et respecte le régime de chaque membre qui en a un — calculé
|
||
serveur-side, jamais exposé en données brutes par membre : les
|
||
allergies/régimes d'un membre restent privés, même logique que la visibilité
|
||
404-jamais-403), `ingredientIds`/`dietIds` (ET logique — la recette doit
|
||
porter *chacun*, pas au moins un).
|
||
|
||
**Filtrage par sources activées** (`sourceVisibilityWhere`) — appliqué à
|
||
**chaque** onglet : une recette manuelle (`sourceId` `null`) est toujours
|
||
visible, seule une recette issue d'une source externe non activée pour le
|
||
foyer du viewer est masquée. Sans foyer, rien n'est activé par construction
|
||
(pas de ligne `HouseSource` à référencer) — toute recette sourcée est
|
||
invisible tant que le profil n'a pas rejoint/créé de foyer.
|
||
|
||
### Détection des techniques — `tech-step-matcher.ts`
|
||
|
||
Historiquement une table `TechStepMapping` de regex par technique/locale
|
||
(`weight` pour départager les chevauchements) — remplacée par un pipeline
|
||
`node-nlp` (`TechStepClassifierService`) une fois constaté que les regex ne
|
||
généralisaient jamais au-delà de leur propre vocabulaire : une étape décrivant
|
||
la fonte du beurre comme "jusqu'à ce que le beurre ait disparu dans la poêle"
|
||
ne contient aucun verbe sur lequel une regex pourrait s'ancrer, alors que le
|
||
sens est sans ambiguïté. `TechStepMapping` a été supprimée (migration
|
||
`20260821130000_drop_tech_step_mapping`) — plus aucune table n'est
|
||
interrogée/éditée à l'exécution, les données de matching vivent en code
|
||
(`tech-step-training-data.ts`).
|
||
|
||
`node-nlp` a ensuite été remplacé à son tour par un microservice Python dédié,
|
||
`services/tech-step-intent-service` (spaCy — `PhraseMatcher` + `textcat`),
|
||
appelé en HTTP par `TechStepClassifierService` via `IntentServiceClient`
|
||
(`intent-service-client.ts`) — `node-nlp` était peu maintenu et tournait
|
||
in-process dans l'event loop Node ; spaCy offre un écosystème NLP plus
|
||
robuste, dans un processus séparé, avec l'ambition à terme de pouvoir aussi
|
||
absorber ce que fait `services/tech-step-llm-worker`. Ce service est
|
||
entièrement autonome : `TECH_STEP_TRAINING_DATA` (~74 techniques) vit
|
||
désormais dans son propre `training_data.py`, revu par PR comme le reste du
|
||
code mais plus poussé par `apps/api` via HTTP — le service s'entraîne
|
||
lui-même une seule fois, à son propre démarrage, et ne touche jamais
|
||
Postgres (voir son propre README, y compris pour le temps de démarrage —
|
||
plusieurs minutes, l'entraînement n'étant jamais persisté sur disque).
|
||
|
||
`normalizeText` (décomposition NFD + suppression des diacritiques + minuscule)
|
||
reste utilisée par `ingredient-matcher.ts`, mais n'intervient plus dans la
|
||
détection des techniques elle-même — un port Python de cette même fonction
|
||
(`intent_service/text_normalization.py`) alimente le composant de
|
||
normalisation du pipeline spaCy côté service.
|
||
|
||
**Pipeline en 3 étapes** (`TechStepClassifierService.matchTechStepSpans`) :
|
||
1. **NER** (le `PhraseMatcher` du service, construit depuis les `synonyms` de
|
||
`TECH_STEP_TRAINING_DATA`) trouve chaque mention *candidate* d'une
|
||
technique dans la description entière, avec sa position exacte —
|
||
équivalent mécanique des anciennes regex, en listes de synonymes plutôt
|
||
qu'en patterns écrits à la main. Le matching se fait sur une normalisation
|
||
stricte (accents/casse) sans tolérance floue de type Levenshtein — voir
|
||
`services/tech-step-intent-service/intent_service/locale_pipeline.py`.
|
||
2. La description est découpée en clauses autour de ces candidats
|
||
(`splitIntoClauses`, pure/testable sans modèle) — une étape nommant deux
|
||
techniques a besoin que chacune soit jugée sur son propre contexte, pas
|
||
la phrase entière classée d'un bloc.
|
||
3. **Classification d'intention NLP** (le `textcat` du service, entraîné sur
|
||
les `utterances` de `TECH_STEP_TRAINING_DATA`) classe chaque clause
|
||
individuellement — c'est ce qui apporte la compréhension du **sens** :
|
||
le corpus d'entraînement mélange volontairement des tournures ancrées sur
|
||
le mot-clé et des paraphrases qui ne l'emploient jamais (ex. "jusqu'à ce
|
||
que le beurre ait disparu" pour `melt`), donc le verdict final d'une
|
||
clause vient de ce que le modèle reconnaît comme *signifiant* la
|
||
technique, pas du mot littéral qui a déclenché son découpage. En dessous
|
||
de `CONFIDENCE_THRESHOLD` (voir la constante dans `tech-step-matcher.ts`
|
||
pour la valeur courante et comment elle a été calibrée), retombe sur la
|
||
technique impliquée par l'ancre NER de la clause plutôt que d'abandonner
|
||
un match clairement ancré sur un mot-clé juste parce que le modèle n'est
|
||
pas assez confiant.
|
||
|
||
Résolution `TechStep.key -> id` mémoïsée une seule fois sur le singleton
|
||
partagé `techStepClassifier` (jamais par requête) — c'est tout ce
|
||
qu'`apps/api` a encore à mémoïser, l'entraînement du modèle lui-même vivant
|
||
entièrement côté `services/tech-step-intent-service`. `server.ts` appelle
|
||
`techStepClassifier.warmUp()` avant d'accepter du trafic, avec retry/backoff
|
||
si `services/tech-step-intent-service` n'est pas encore joignable (le cas
|
||
normal en Docker Compose, où `app` attend qu'il soit `healthy` avant même de
|
||
démarrer — voir `docker-compose.yml`, et le README de ce service pour
|
||
combien de temps ça prend).
|
||
|
||
**Pièges rencontrés en construisant ce pipeline**, tous corrigés dans le code
|
||
(pas juste contournés) :
|
||
- `db/prisma.ts` construisait `new PrismaClient()` sans jamais importer
|
||
`config/env.ts` — dans le run de test complet, un *autre* fichier
|
||
chargeait toujours `config/env.ts` (donc `.env.test`) en premier par pur
|
||
hasard d'ordre de résolution des modules ; lancer un seul fichier de test
|
||
isolément pouvait faire gagner la course au chargement `.env` interne de
|
||
Prisma (le chemin `.env` de dev, baké dans le client généré) — silencieux
|
||
tant que `resetDatabase()` ne throw pas (heureusement son garde-fou le
|
||
fait). Fixé en import `config/env.js` pour effet de bord tout en haut de
|
||
`prisma.ts`, avant `new PrismaClient()`.
|
||
- (historique, node-nlp) `NlpManager` avait `autoSave`/`autoLoad: true` par
|
||
défaut — persistait le modèle entraîné dans un fichier `model.nlp` (cwd du
|
||
process) et le rechargeait *au lieu de* ré-entraîner au prochain démarrage
|
||
s'il existait déjà. Un modèle obsolète sur disque aurait masqué
|
||
silencieusement toute mise à jour du corpus. Non applicable au service
|
||
Python actuel : il réentraîne tout en mémoire à chaque démarrage du
|
||
process, sans jamais rien persister sur disque (voir ce service's own
|
||
README).
|
||
- `nlp.make_doc()` (spaCy) ne fait tourner que le tokenizer, pas les
|
||
composants du pipeline — un piège trouvé en construisant le `PhraseMatcher`
|
||
du nouveau service : les patterns de synonymes doivent explicitement
|
||
repasser par le composant de normalisation, sinon un synonyme accentué
|
||
("préchauffer") ne matche jamais sa forme normalisée dans le texte cible
|
||
(voir le commentaire dans `locale_pipeline.py`'s `train()`).
|
||
|
||
### Résolution ingrédients/unités — `ingredient-matcher.ts`
|
||
|
||
**Anglais uniquement** aujourd'hui (commit "matching anglais pour les tech
|
||
steps et les ingrédients") :
|
||
- `matchIngredientName` tokenise (minuscule + suppression diacritiques +
|
||
découpage sur non-lettres + un "stemming" naïf de pluriel, pas un vrai
|
||
stemmer linguistique) le texte libre et chaque libellé du catalogue
|
||
(`INGREDIENT_LABELS_EN`, `packages/shared`), cherche chaque libellé comme
|
||
**sous-séquence contiguë de tokens** dans le nom — le plus long (le plus
|
||
spécifique) l'emporte ("chicken breast" bat "chicken"), égalité départagée
|
||
par l'id le plus petit (déterminisme).
|
||
- `matchUnit` compare des tokens entiers (pas de sous-chaîne — "cup" ne doit
|
||
pas matcher à l'intérieur d'un mot plus long sans rapport).
|
||
- `extractQuantity` extrait un nombre/une fraction/un nombre mixte en tête de
|
||
texte libre (`"1 1/2"` → 1.5) si la source n'a pas fourni de quantité
|
||
structurée.
|
||
|
||
### Orchestration — `recipe-translation.ts`
|
||
|
||
`translateRecipe(recipe, locale)` — matche toujours les techniques contre
|
||
`locale`, mais ne charge/matche les catalogues ingrédient/unité **que si
|
||
`locale === "en"`** ; pour toute autre langue, `ingredientId`/`unitId` restent
|
||
`null` sur chaque ligne (dégradation "pas de données dans cette langue").
|
||
Piège documenté explicitement : une source anglaise (TheMealDB) traduite
|
||
contre les mappings `"fr"` obtiendrait un `techStepIds` **vide** sur chaque
|
||
étape (aucune règle de mapping anglaise n'existe encore) — cette étape
|
||
n'invente rien. `sources.service.ts`'s `previewSourceItem` (brouillon, non
|
||
sauvegardé) et `recipe.service.ts`'s `createRecipe`/`updateRecipe`/
|
||
`createImportedRecipe` (sauvegarde réelle) partagent exactement les mêmes
|
||
briques.
|
||
|
||
---
|
||
|
||
## Isolation de la base de test
|
||
|
||
`pnpm test` (`apps/api`) exécute une `TRUNCATE ... CASCADE` sur presque tout
|
||
le schéma **avant chaque test** (`test-support/reset-db.ts`'s
|
||
`resetDatabase()`) — auparavant partagée avec la base de dev via un seul
|
||
`.env`, ce qui a un jour vidé un vrai foyer/compte de dev en cours de test
|
||
(irrécupérable, `TRUNCATE`, pas de sauvegarde). Fix, trois pièces :
|
||
|
||
1. `config/env.ts` charge `.env.test` au lieu de `.env` quand
|
||
`NODE_ENV=test` (posé par `cross-env` dans le script `"test"` de
|
||
`apps/api/package.json`, avant que ce module ne s'exécute).
|
||
2. `resetDatabase()` appelle désormais `assertRunningAgainstTestDatabase()` en
|
||
tout premier : lève si `NODE_ENV !== "test"`, **et** si `DATABASE_URL` ne
|
||
contient ni `"test"` ni `"ci"` — la branche `"ci"` a dû être ajoutée après
|
||
coup, la base de CI s'appelant `batchcooking_ci` (pas `batchcooking_test`),
|
||
ce que le garde-fou initial rejetait à tort (282 tests en échec). Le seul
|
||
nom que ce garde-fou doit encore rejeter est la vraie base de dev,
|
||
`batchcooking`.
|
||
3. Nouveaux fichiers `apps/api/.env.test` (local, gitignored) et
|
||
`.env.test.example` (committé, template + commandes pour créer la base) —
|
||
même serveur/identifiants Postgres que `.env`, juste un nom de base
|
||
différent.
|
||
|
||
**À faire une fois par machine** avant `pnpm test` : créer `apps/api/.env.test`
|
||
pointant vers une base **différente** (ex. `batchcooking_test`) — voir
|
||
`.env.test.example` pour les commandes exactes (`CREATE DATABASE`, `prisma
|
||
migrate deploy`). Ce fichier n'est pas créé automatiquement, contrairement à
|
||
`.env`.
|
||
|
||
En CI (`.github/workflows/ci.yml`), le job `test` provisionne son propre
|
||
service `postgres:16-alpine` (`POSTGRES_DB: batchcooking_ci`) et définit
|
||
`DATABASE_URL` au niveau du workflow — exactement le cas que la branche
|
||
`"ci"` du garde-fou existe pour accepter.
|
||
|
||
---
|
||
|
||
## Compte — suppression, pas encore de changement d'email/mot de passe
|
||
|
||
`DELETE /auth/me` (`auth.routes.ts`, `requireAuth`) — supprime définitivement
|
||
le profil courant après **revérification du mot de passe**
|
||
(`deleteAccountSchema`, `packages/shared/src/schemas/account.ts`) : 401
|
||
`INVALID_CREDENTIALS` si le mot de passe ne correspond pas (`argon2.verify`),
|
||
sinon `leaveCurrentHouse` (gère transfert d'adminship/suppression du foyer,
|
||
voir plus haut) puis suppression du profil (cascade `UserProfileAllergy` via
|
||
`onDelete: Cascade`). Aucune route de changement d'email/mot de passe n'existe
|
||
encore — `AccountSettingsPage` (web) n'affiche l'identité qu'en lecture seule.
|
||
|
||
`UserProfile.tokenVersion` (bump prévu pour invalider les JWT déjà émis, ex. à
|
||
un futur changement de mot de passe) existe déjà dans le schéma et est vérifié
|
||
à chaque requête par `requireAuth`, mais **rien ne l'incrémente encore** —
|
||
c'est une infrastructure posée à l'avance, pas encore câblée à une
|
||
fonctionnalité réelle.
|
||
|
||
---
|
||
|
||
## Pas de fichiers `.d.ts` écrits à la main
|
||
|
||
Voir [frontend-architecture.md](./frontend-architecture.md#note-sur-les-fichiers-dts)
|
||
pour le détail côté `apps/web`. Côté `apps/api` : aucune augmentation de type
|
||
globale (`declare global`) n'est utilisée — voir la section `res.locals`
|
||
ci-dessus, qui est précisément ce qui aurait nécessité ce genre de fichier.
|