* 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>
37 KiB
Architecture backend — Projet Batch-cooking
Documentation de l'organisation d'
apps/apiet 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 :
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'unRoutercomplet, 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 instanceExpress, 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
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 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 :
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 :
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 :
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, 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.
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.nullrecouvre deux états normaux confondus : pas de foyer, ou aucunPlanningne couvre cette date — jamais une erreur.POST /planning/items→addPlanningItem, 201. BodyaddPlanningItemSchema={ date, weekDay, meal, recipeId, portions }(weekDay/mealsont des enumsWEEK_DAYS/MEALSdepackages/shared, réellement validés ici — pas juste une convention documentée).recipeIddoit exister et être visible par l'appelant (assertRecipeVisible,recipe.service.ts) → 404RECIPE_NOT_FOUNDsinon.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, 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, 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) 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.tsappelleregisterAllRecipeSources()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 leCMDduDockerfile:prisma migrate deploy && node dist/scripts/seed-runtime.js && node dist/server.js. Nécessaire car chaque maillon duCMDest un processusnodeséparé : sans cette étape dédiée, le registre peuplé parserver.tsne touchait jamais la base en production, etGET /reference/sourcesrenvoyait silencieusement[](toute la section "Sources" deHouseholdSettingsPagerestait invisible) — bug corrigé par le commit "synchronise les sources en base au démarrage de l'image de prod". Vit soussrc/(pasprisma/) précisément pour être compilé dansdistpartsc, l'image runtime n'embarquant quedist, passrc.test-support/reset-db.ts'sresetDatabase()appelle aussisyncRecipeSourcesen 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_KEYenv 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é (nextCursortoujoursnull).parse()reconstruit les ingrédients depuis les paires platesstrIngredient1..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é danssources/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 uneSourceactivable 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'sexternalIdest directement l'URL cible, pas un id issu d'unlist()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) 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) :
- NER (le
PhraseMatcherdu service, construit depuis lessynonymsdeTECH_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 — voirservices/tech-step-intent-service/intent_service/locale_pipeline.py. - 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. - Classification d'intention NLP (le
textcatdu service, entraîné sur lesutterancesdeTECH_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" pourmelt), 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 deCONFIDENCE_THRESHOLD(voir la constante danstech-step-matcher.tspour 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.tsconstruisaitnew PrismaClient()sans jamais importerconfig/env.ts— dans le run de test complet, un autre fichier chargeait toujoursconfig/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.envinterne de Prisma (le chemin.envde dev, baké dans le client généré) — silencieux tant queresetDatabase()ne throw pas (heureusement son garde-fou le fait). Fixé en importconfig/env.jspour effet de bord tout en haut deprisma.ts, avantnew PrismaClient().- (historique, node-nlp)
NlpManageravaitautoSave/autoLoad: truepar défaut — persistait le modèle entraîné dans un fichiermodel.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 lePhraseMatcherdu 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 danslocale_pipeline.py'strain()).
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'sfindIngredientMentionsscanne le texte de la clause contre le catalogueIngredientexistant (INGREDIENT_LABELS_FR/_EN,packages/shared— le même quematchIngredientNameutilise 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, voirQUANTITY_BEFORE_INGREDIENT_PATTERN) —null/nullsinon, 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 secondPhraseMatcherindépendant du premier (pas detextcat— un ustensile mentionné n'a pas besoin d'être interprété, contrairement à une technique).POST /v1/processrenvoie donc deux types d'entité discriminés parkind: "technique" | "utensil"dans la même listeentities.
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") :
matchIngredientNametokenise (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).matchUnitcompare des tokens entiers (pas de sous-chaîne — "cup" ne doit pas matcher à l'intérieur d'un mot plus long sans rapport).extractQuantityextrait 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 :
config/env.tscharge.env.testau lieu de.envquandNODE_ENV=test(posé parcross-envdans le script"test"deapps/api/package.json, avant que ce module ne s'exécute).resetDatabase()appelle désormaisassertRunningAgainstTestDatabase()en tout premier : lève siNODE_ENV !== "test", et siDATABASE_URLne contient ni"test"ni"ci"— la branche"ci"a dû être ajoutée après coup, la base de CI s'appelantbatchcooking_ci(pasbatchcooking_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.- 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
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.