* feat(recipes): associe ingredients, quantites et ustensiles aux techniques detectees
Etend le pipeline de detection de techniques (tech-step-matcher.ts) pour
resoudre, par clause, les metadonnees qui accompagnent une technique
detectee :
- Ingredients : nouvelle fonction findIngredientMentions (ingredient-matcher.ts)
qui scanne le texte d'une clause contre le catalogue Ingredient existant
(reutilise INGREDIENT_LABELS_FR/EN deja utilise par matchIngredientName),
avec extraction best-effort de la quantite+unite immediatement avant la
mention.
- Ustensiles : nouveau catalogue Utensil (Prisma) + second PhraseMatcher
cote service Python (intent_service/utensil_vocabulary.py), independant
du textcat des techniques (pas d'interpretation necessaire pour un
ustensile). POST /v1/process distingue desormais chaque entite via un
champ kind (technique|utensil).
- Persistance : deux nouvelles tables StepTechStepIngredient/
StepTechStepUtensil, liees a StepTechStep par sa cle composite
(stepId, order), peuplees au moment du matching (recipe.service.ts) et
exposees via StepTechStepView (packages/shared).
Aucune analyse syntaxique ajoutee (le parser spaCy reste exclu du
pipeline) : l'association se fait par appartenance a la clause deja
calculee par splitIntoClauses.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(recipes): corrige les tests casses par les nouveaux champs ingredients/utensils
recipe-tech-step-correction.test.ts asserte StepTechStepView en dur sans
les nouveaux champs ingredients/utensils (toujours [] pour une correction
manuelle, qui ne repasse jamais par le scan de metadonnees).
Retire aussi le nouveau cas de tech-step-matcher.test.ts qui inventait une
phrase jamais vue par le corpus reel : verifie en CI que le textcat la
classe avec confiance comme caramelize plutot que melt, un artefact du
petit corpus BOW plutot qu'un bug du code de matching. L'extraction
quantite+unite reste couverte integralement et de facon deterministe par
ingredient-matcher.test.ts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(recipes): equilibre le corpus d'entrainement du textcat a 20 phrases par technique
Chaque technique n'avait que 3 a 7 utterances par locale (moyenne ~3.8),
un desequilibre reel entre classes qui contribue directement a des
classifications confiantes mais fausses sur une formulation jamais vue
(constate concretement dans la PR precedente : une phrase inedite pour
melt classee comme caramelize avec une confiance elevee).
Porte chaque technique a exactement 20 utterances par locale (fr et en) :
- Les utterances existantes sont conservees telles quelles, jamais
reecrites.
- Le complement vient d'augment_utterances.py (nouveau script maintainer,
reutilisable pour une future technique sous-alimentee) : enveloppe
chaque utterance deja a l'imperatif/infinitif dans une tournure modale
grammaticalement valide (il faut/veillez a/make sure to...) plutot que
de dupliquer ou d'inventer du texte generique - vraie diversite de
surface, vocabulaire distinctif de la technique intact.
- tests/test_training_data_balance.py fait respecter l'invariant en CI
(20 minimum, meme nombre fr/en) pour toute future modification.
_TRAINING_ITERATIONS recalibre de 25 a 10 (locale_pipeline.py) pour
compenser les ~2.6x d'exemples par epoque : temps d'entrainement mesure
quasi identique a avant (~687s fr+en combines contre ~670s), confiance
egale ou meilleure sur les cas deja suivis (simmer 0.31 -> 0.48).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(recipes): remonte _TRAINING_ITERATIONS a 20, la gate F1 de CI etait sous 0.8 a 10
Le premier passage CI de l'equilibrage du corpus (20 utterances/technique)
a fait chuter le F1 agrege (tech-step-eval.test.ts) a 0.7999... avec
_TRAINING_ITERATIONS=10 : le pari qu'un corpus plus large convergerait en
moins d'epoques relatives etait faux a ce niveau de reduction. Remonte a
20 (mesure : ~699s pour la seule locale fr, previsiblement ~1360s pour
fr+en combines) - confiance nettement retablie sur les techniques
auparavant en echec au spot-check manuel (sweat ~0.99).
Consequence directe : le temps de demarrage du service passe d'environ
11 a environ 23 minutes. start_period (docker-compose.yml) et le timeout
d'attente /health (ci.yml) releves de 900s a 1800s en consequence.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(recipes): reequilibre le corpus via substitution de synonyme plutot que du remplissage generique
Deux tentatives precedentes de porter chaque technique a 20 utterances
ont mesurablement degrade le F1 agrege (tech-step-eval.test.ts, 0.80 ->
0.79/0.791) au lieu de l'ameliorer : le generateur reposait surtout sur
des tournures modales generiques ("il faut ...", "make sure to ..."),
partagees identiquement par les 74 classes - un textcat bag-of-words lit
ca comme une separabilite reduite entre classes, pas un padding neutre.
augment_utterances.py revu : priorite a la substitution de synonyme
(l'un des synonyms propres a la technique en tete d'une utterance
existante, remplace par un autre - vocabulaire genuinement distinctif),
les tournures modales ne servant plus qu'de complement limite (5 par
locale, pas 12). Resultat : 13 a 20 utterances par technique/locale
(moyenne ~19.7), contre un forcage uniforme a 20 qui necessitait un
remplissage generique disproportionne pour les techniques au vocabulaire
propre pauvre (julienne, sweat, bainMarie - precisement celles qui
echouaient). Confiance mesuree nettement retablie sur ces techniques
(sweat ~0.99, bainMarie ~0.98, julienne ~0.88).
tests/test_training_data_balance.py : plancher abaisse a 12 (vise 20,
garanti seulement si le vocabulaire propre de la technique le permet
sans repasser par le piege ci-dessus) ; suppression de l'exigence
fr/en egaux, plus vraie avec cette strategie (le potentiel de
substitution differe naturellement entre les deux langues).
Suite complete locale : 35/35 verts (22m26s).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* revert(recipes): annule le reequilibrage du corpus d'entrainement du textcat
Trois strategies de generation differentes (tournures modales generiques,
tournures reduites + substitution de synonyme, substitution de synonyme
en priorite) ont ete tentees pour porter chaque technique a 20 utterances
par locale. Les trois degradent mesurablement le F1 agrege contre
TECH_STEP_EVAL_DATASET (tech-step-eval.test.ts) en dessous du seuil 0.8 :
0.7999 -> 0.791 -> 0.744 (chaque tentative pire que la precedente).
tech-step-eval-runner.ts documente explicitement ce seuil comme calibre
avec une marge deja tres etroite (0.8 pour un score mesure a 0.815) et
previent contre le fait de l'assouplir pour accommoder un classifieur
plus faible plutot que de corriger le probleme de fond - assouplir le
seuil ou le jeu d'evaluation pour faire passer cette PR irait a l'encontre
de cette convention documentee du projet.
Revient a l'etat d'avant tout reequilibrage (corpus a 3-7 utterances/
technique, _TRAINING_ITERATIONS=25, timeouts a 900s) - le dernier etat
confirme vert en CI sur cette branche. Ameliorer reellement l'equilibre
du corpus necessite du contenu redige a la main et verifie technique par
technique contre ce meme F1, pas une generation programmatique en bloc.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(recipes): reequilibre le corpus via substitution de synonyme plutot que du remplissage generique
Trois tentatives precedentes d'egaliser chaque technique a 20 utterances
ont toutes degrade le F1 agrege sous 0.8 (voir le commit revert
precedent). Nouvelle strategie, beaucoup plus conservatrice : egalise
chaque technique vers le maximum DEJA present dans le corpus (7 en fr,
5 en en, portes par cook/preheat), pas vers un nombre choisi dans
l'absolu - +3-4 utterances en moyenne par technique au lieu de +13-17.
augment_utterances.py (nouveau, reutilisable) genere le complement en
priorite par substitution de synonyme (un des synonyms propres a la
technique, en tete d'une utterance existante, remplace par un autre) -
avec un garde-fou supplementaire par rapport aux tentatives precedentes :
le synonyme de remplacement doit lui aussi etre a l'imperatif/infinitif,
pas juste le synonyme d'origine, pour eviter de substituer un groupe
nominal/adjectif ("a petit feu", "gros bouillons") a la place d'un
verbe et produire une phrase grammaticalement cassee. Tournures modales
uniquement en dernier recours pour les techniques dont le vocabulaire
n'apparait qu'en milieu de phrase (julienne, brunoise...).
Resultat : chaque technique a exactement 7 utterances en fr et 5 en en,
sans exception (tests/test_training_data_balance.py fait respecter cet
invariant). _TRAINING_ITERATIONS reste a 25 (inchange). start_period/
timeout d'attente /health releves de 900s a 1200s (temps d'entrainement
mesure ~930s contre ~670s avant, la marge de securite existante etait
devenue trop juste).
Suite complete locale : 35/35 verts (14m41s).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* chore: retrigger CI (aucun run genere pour c7116d4, probable incident GitHub Actions)
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
677 lines
37 KiB
Markdown
677 lines
37 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()`).
|
||
|
||
**Métadonnées d'action — ingrédients, quantités, ustensiles.** Chaque
|
||
occurrence de technique (`TechStepMatch`) porte aussi ce qui a été détecté
|
||
dans sa propre *clause* (celle calculée à l'étape 2 ci-dessus) :
|
||
- **Ingrédients** — `ingredient-matcher.ts`'s `findIngredientMentions` scanne
|
||
le texte de la clause contre le catalogue `Ingredient` *existant*
|
||
(`INGREDIENT_LABELS_FR`/`_EN`, `packages/shared` — le même que
|
||
`matchIngredientName` utilise déjà pour les listes structurées), plutôt que
|
||
de dupliquer ce catalogue côté service Python. Une quantité+unité
|
||
immédiatement avant la mention est résolue au mieux (regex ancrée sur la
|
||
*fin* du texte précédent, voir `QUANTITY_BEFORE_INGREDIENT_PATTERN`) —
|
||
`null`/`null` sinon, jamais une erreur.
|
||
- **Ustensiles** — contrairement aux ingrédients, ce catalogue n'existait
|
||
nulle part avant cette fonctionnalité : il est né directement côté service
|
||
Python (`intent_service/utensil_vocabulary.py`), via un second
|
||
`PhraseMatcher` indépendant du premier (pas de `textcat` — un ustensile
|
||
mentionné n'a pas besoin d'être interprété, contrairement à une technique).
|
||
`POST /v1/process` renvoie donc deux types d'entité discriminés par
|
||
`kind: "technique" | "utensil"` dans la même liste `entities`.
|
||
|
||
Dans les deux cas, l'association à une technique se fait par appartenance à
|
||
la même clause — pas d'analyse syntaxique (le `parser` spaCy reste exclu du
|
||
pipeline, voir `_EXCLUDED_COMPONENTS`), juste "cette mention tombe dans
|
||
`[clause.start, clause.end)`". Persisté comme `StepTechStepIngredient`/
|
||
`StepTechStepUtensil`, deux tables référençant `StepTechStep` par sa clé
|
||
composite `(stepId, order)`.
|
||
|
||
### 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.
|