batchCooking/specs/backend-architecture.md
kyuno053 bf58834aa9
feat(recipes): migre la detection des tech steps de node-nlp vers un microservice Python spaCy
* feat(recipes): migre la detection des tech steps de node-nlp vers un microservice Python spaCy

Remplace TechStepClassifierService's node-nlp (NlpManager) par
services/tech-step-intent-service, un microservice FastAPI/spaCy dedie
(PhraseMatcher pour le NER par synonymes, textcat pour la classification
d'intention). Corpus (TECH_STEP_TRAINING_DATA) toujours possede par
apps/api, pousse au service via POST /v1/train a chaque warm-up ; le
service ne touche jamais Postgres (meme posture que
services/tech-step-llm-worker).

Cote apps/api :
- intent-service-client.ts : client HTTP vers le nouveau service
- tech-step-matcher.ts : delegue NER + intent classification au client,
  logique pure (splitIntoClauses, seuil/fallback) inchangee
- env.ts : INTENT_SERVICE_BASE_URL/INTENT_SERVICE_SECRET (secret requis,
  service coeur non optionnel)
- server.ts : warm-up avec retry/backoff (service Python demarre a part)
- scripts/calibrate-tech-step-threshold.ts : recalibration empirique de
  CONFIDENCE_THRESHOLD contre le jeu d'eval existant
- node-nlp retire (package.json, node-nlp.d.ts, model.nlp du .gitignore)

docker-compose.yml : nouveau service tech-step-intent-service (pas de
port expose, healthcheck, app en depend). CI : job intent-service-test
(pytest) + le job test demarre le service en arriere-plan avant la suite
Mocha (jamais de mock d'un service interne, cf specs/dev-conventions.md).

Verifie : 26/26 tests pytest du service (dont les offsets caracteres
exacts de tech-step-matcher.test.ts), lint + build complets du monorepo,
smoke test HTTP reel bout en bout. La suite Mocha et docker compose
build/up n'ont pas pu etre executes dans cet environnement (pas de
Postgres/Docker disponibles ici) — a confirmer via la CI et en local.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(recipes): corrige les matches dupliques et le timeout de warm-up des tests CI

Deux bugs reels trouves par la premiere execution CI de la migration
node-nlp -> tech-step-intent-service :

1. PhraseMatcher retourne tous les matches y compris chevauchants — un
   synonyme comme "fondre" litteralement contenu dans "faire fondre" (tous
   deux synonymes de `melt`) produisait deux candidats separes pour la meme
   technique, dupliquant son techStepId dans le resultat final. Fixe avec
   spacy.util.filter_spans (garde le plus long match par position) dans
   LocalePipeline.process. Test de non-regression ajoute.

2. La suite Mocha construit `app` directement via createApp(), sans jamais
   passer par server.ts — le warm-up (POST /v1/train fr+en sur le corpus
   complet) se declenchait donc paresseusement dans le premier test qui
   appelait le classifieur, depassant le timeout Mocha de 10s par test.
   Fixe par un root hook plugin Mocha (test-support/mocha-root-hooks.ts,
   .mocharc.json) qui reset la DB et warm up le classifieur une seule fois
   avant toute suite, avec son propre timeout de 60s.

Verifie : 27/27 tests pytest du service (dont le nouveau test de
non-regression), lint + build complets du monorepo. La suite Mocha
elle-meme n'a toujours pas pu etre executee dans cet environnement (pas de
Postgres disponible ici) — a confirmer via la CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* ci(temp): ajoute un run de calibrate-tech-step-threshold.ts pour observation

Etape temporaire pour lire le sweep de seuils de confiance contre le vrai
service tech-step-intent-service en CI (aucun Postgres/service disponible
localement dans cette session) — sera retiree une fois CONFIDENCE_THRESHOLD
recalibre dans tech-step-matcher.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(recipes): recalibre CONFIDENCE_THRESHOLD pour le nouveau classifieur spaCy

0.75 (calibre a l'origine contre node-nlp) laissait de vrais verdicts
corrects sur des clauses sans ancre NER (rien sur quoi retomber) sous le
seuil : melt scorait 0.68 sur "jusqu'a ce que le beurre ait disparu dans
la poele" (le cas motivant tout ce pipeline), preheat 0.52 sur "mettre la
poele sur feu vif" — tous deux corrects, tous deux rejetes a 0.75.

Recalibre a 0.45 : marge confortable au-dessus du bruit (texte anglais
via le classifieur francais score ~0.04, indiscernable du hasard sur ~26
classes) et sous les deux cas ci-dessus. Confirme par
calibrate-tech-step-threshold.ts contre TECH_STEP_EVAL_DATASET (F1
plafonne a 0.987 des 0.45, reste plat jusqu'a 0.95 — 0.45 est deja le
seuil le plus bas qui capture tout le gain disponible).

Retire l'etape CI temporaire de calibration (ci.yml) une fois la valeur
choisie.

Verifie : lint + build complets du monorepo, 27/27 pytest du service,
sweep de seuils + verification manuelle contre le corpus reel en local
(services Python, sans Postgres) et en CI. La suite Mocha complete reste
a confirmer sur ce commit (executee en CI, pas localement — pas de
Postgres disponible dans cet environnement).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(recipes): entraine le textcat plus longtemps pour une confiance reelle

Cause racine du dernier test Mocha en echec (getAuditBatch flaguait
"Faire mijoter a feu doux" comme peu fiable malgre une ancre NER claire) :
avec seulement 30 iterations/dropout 0.2, le textcat retournait le bon
intent (argmax correct) mais avec une confiance tres basse et compressee
(0.2-0.7 sur l'ensemble du corpus reel, y compris des cas evidents) —
un vrai probleme de qualite d'entrainement, pas seulement de seuil.

150 iterations / lot de 16 / dropout 0.1 (mesure localement contre le
vrai corpus, sans Postgres) : melt ~0.95, preheat ~0.90, jusqu'a ~0.51
pour le cas le plus faible observe (bake), bruit hors-vocabulaire toujours
~0.05. ~110s d'entrainement par locale (~220s pour fr+en au warm-up) —
compromis assume et documente (README du service, commentaires du code),
contrairement a l'entrainement quasi instantane de node-nlp.

Root hook Mocha (mocha-root-hooks.ts) et sa doc mis a jour avec un timeout
de 600s pour couvrir cette duree avec marge.

Verifie : 27/27 pytest, lint + build complets du monorepo. Suite Mocha a
confirmer sur ce commit via CI (source du diagnostic qui a mene a ce fix).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(recipes): journalise chaque input/output du pipeline NLP

Ajoute un logging JSON structure (meme convention que LoggerService cote
apps/api) a services/tech-step-intent-service : chaque appel
POST /v1/process journalise locale/texte en entree et
entites/intent/score en sortie, chaque POST /v1/train journalise les uid
entraines et les compteurs resultants. Chatter interne de spaCy mis a
WARNING pour ne pas noyer ces lignes.

Bug trouve et corrige en verifiant les octets bruts d'un log reel (pas
juste son affichage terminal) : l'encodage par defaut de sys.stdout sur
Windows produisait de vrais octets UTF-8 invalides pour tout texte
accentue journalise (le francais des etapes de recette) — corrige par
sys.stdout.reconfigure(encoding="utf-8") au demarrage.

LOG_LEVEL configurable (INFO par defaut), documente dans le README du
service et .env.example.

Verifie : 30/30 pytest (3 nouveaux tests sur le formateur JSON), smoke
test HTTP reel confirmant au niveau des octets que les caracteres
accentues sont preserves, lint complet du monorepo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(recipes): rapatrie le corpus NLP cote Python et l'enrichit de 48 techniques

Changement d'architecture demande par l'utilisateur : le dataset
d'entrainement (TECH_STEP_TRAINING_DATA) quitte apps/api pour vivre
entierement dans services/tech-step-intent-service
(intent_service/training_data.py). Ce service est desormais autonome :
il s'entraine lui-meme une seule fois, a son propre demarrage
(PipelineRegistry.initialize, dans le lifespan FastAPI), sans plus
dependre d'un POST /v1/train pousse par apps/api (route supprimee).
apps/api ne connait plus aucune technique/synonyme, uniquement le
resultat de POST /v1/process.

Corpus enrichi avec les 48 techniques du lexique fourni (Arroser,
Appertiser, Braiser, Caraméliser, Confire, Julienne/Brunoise/Mirepoix/
Paysanne, Cuire à blanc/au bain-marie/à l'étouffée, Déglacer variantes,
Emulsionner, Glacer, Pocher, Réduire, Suer, Zester, etc.), soit 74
techniques au total (26 + 48). Integration complete bout en bout :
- reference-seed-data.ts : 48 nouvelles entrees TECH_STEPS
- apps/web/locales/fr/translation.json : libelles francais correspondants
- "Mitonner" fondu comme synonyme de simmer (pas une technique distincte,
  sa propre definition le dit)
- "Blanchir un oeuf" (whiskPale) distingue de "Blanchir un legume"
  (blanch, existant) via des synonymes en phrase complete plutot qu'au
  mot nu — filter_spans (deja en place) resout la collision par
  specificite

Impact performance mesure : le corpus elargi (74 classes vs 26) rend
l'entrainement bien plus lent a nombre d'iterations egal (150 iterations
depassait 17 minutes par run de test) — reduit a 40 iterations apres
mesures repetees en local (~200s/locale, ~400s pour fr+en combines).
docker-compose.yml (healthcheck start_period 600s), CI (timeout curl
600s) et le README du service documentent ce nouveau temps de demarrage.
CONFIDENCE_THRESHOLD recalibre a 0.2 par verification manuelle (0.75 puis
0.45 ne tenaient plus compte tenu du nombre de classes) — marque
explicitement comme placeholder en attendant une vraie repasse de
calibrate-tech-step-threshold.ts (necessite Postgres, indisponible dans
cet environnement).

Verifie : 28/28 tests pytest du service (suite complete re-ecrite pour
s'entrainer une seule fois par session sur le vrai corpus, fixture
partagee dans conftest.py), lint + build complets du monorepo. La suite
Mocha d'apps/api reste a confirmer via CI (le root hook mocha n'attend
plus l'entrainement, seulement CI's propre attente sur /health).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(recipes): corrige l'assertion de taille du catalogue TechStep en dur

test/reference.test.ts attendait exactement 26 techniques (l'ancien
catalogue) au lieu de deriver la longueur attendue de TECH_STEPS
(reference-seed-data.ts) — trouve par la CI apres l'ajout des 48
nouvelles techniques (74 au total). Seul echec du run CI precedent, le
service Python (nouveau corpus, self-training) a lui demarre et repondu
correctement dans le nouveau delai imparti.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(recipes): entraine le textcat plus longtemps pour une confiance reelle

Suite a une revue de code sur locale_pipeline.py, trois ameliorations
implementees et verifiees contre le vrai corpus (74 techniques) :

- spacy.util.fix_random_seed(_TRAINING_SEED) avant nlp.initialize() —
  random.Random() ne graine que l'ordre de melange des exemples, pas
  l'init des poids/dropout internes de thinc.
- _DiacriticsNormalizer deplace au-dessus de sa factory @Language.factory
  — plus d'annotation de type en chaine.
- Log explicite (logger.warning) quand train() recoit moins de 2 labels
  et saute la creation du textcat, plus une clarification de la docstring
  de process() sur les deux cas menant a intent=None.
- Early stopping avec suivi de la perte par epoque, _TRAINING_ITERATIONS
  restant le plafond. Mesure sur le vrai corpus : ne se declenche jamais
  dans le budget actuel de 40 iterations (la perte continue de baisser
  significativement jusqu'au bout) — documente honnetement comme filet
  de securite pour un futur relevement du plafond, pas un gain de temps
  aujourd'hui.

Deux suggestions de la revue examinees et non retenues, avec
justification en commentaire : le risque de desalignement pattern/texte
via normalize_text (normalize_text opere par token deja tokenise, jamais
sur la chaine brute — pas de risque de segmentation differente) ; passer
a attr="LOWER" aurait au contraire regresse l'insensibilite aux accents
que attr="NORM" fournit deliberement.

Verifie : 28/28 pytest (dont le vrai corpus complet via la fixture
partagee), lint du monorepo. Temps d'entrainement mesure stable
(~200-230s/locale, dans la marge de bruit deja documentee).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(recipes): entraine le textcat sur les synonymes en plus des utterances

Suite a une suggestion de revue de code : le textcat n'apprenait
jusqu'ici que sur entry.utterances, jamais sur entry.synonyms (deja
utilises pour le PhraseMatcher). Ajouter le mot-cle isole comme exemple
positif de sa propre technique ameliore radicalement la confiance sur
les cas ancres sans paraphrase entrainee.

Mesures sur le vrai corpus (74 techniques) :
- 40 iterations + synonymes (749 exemples vs 286 avant) : gain de
  confiance massif (simmer 0.25->0.60, cook 0.33->0.60, bake 0.34->0.86)
  mais temps d'entrainement multiplie par 2.6 (~535s/locale, ~17min
  combine pour fr+en — inacceptable).
- 15 iterations + synonymes : retour a un temps raisonnable (~205s) mais
  qualite pire qu'avant (simmer/cook repassent sous le seuil de
  confiance) — les exemples supplementaires ne compensent pas la perte
  d'epoques a ce point.
- 25 iterations + synonymes (retenu) : ~336s/locale (~670s combine),
  meilleur compromis — tous les cas mesures s'ameliorent par rapport a
  la config precedente (simmer 0.25->0.31, cook 0.33->0.38,
  bake 0.34->0.62, zest 0.64->0.66, julienne 0.56->0.76, compote
  0.76->0.78), bruit hors-vocabulaire toujours negligeable (~0.02).

CONFIDENCE_THRESHOLD releve de 0.2 a 0.25 (le cas le plus faible mesure
est maintenant 0.31, avec plus de marge qu'avant). docker-compose.yml
(start_period 900s) et la CI (timeout 900s) ajustes pour le nouveau
temps de demarrage (~11 min pour fr+en combines, contre ~7 min avant).

Deux autres pistes de la meme revue examinees et non retenues avec
justification : classe __OTHER__/negatifs hors-domaine (le bruit mesure
est deja bas, ~0.02, sans le symptome que cette classe corrige) et boost
de score post-traitement si le NER confirme l'intention predite (casserait
la garantie "score brut, jamais corrige par l'ancre" que
services/tech-step-llm-worker's audit de faible confiance depend
explicitement d'avoir, voir le commentaire de TechStepClauseClassification
dans tech-step-matcher.ts).

Verifie : 28/28 pytest (dont le vrai corpus complet, ~10.5 min pour la
suite complete), lint + build du monorepo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 09:03:52 +02:00

36 KiB
Raw Blame History

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 :

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

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/wrapAsyncHandlerLocals 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/sharedassertIsNever

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 defaulterreur 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'invitationgenerateInviteCode() 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-DDgetPlanningForDatePlanningView | null. null recouvre deux états normaux confondus : pas de foyer, ou aucun Planning ne couvre cette date — jamais une erreur.
  • POST /planning/itemsaddPlanningItem, 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/:idremovePlanningItem, 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 semainefindOrCreatePlanningForWeek 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-DDgetShoppingListForDateShoppingListView. 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 (AllergyCategory{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.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.tskey: "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.tskey: "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é (RecipeVisibilityPERSONAL/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 (RecipeTabfavoris/perso/foyer/publique, pas de "toutes" : toute recette visible tombe sous exactement un des trois premiers via sa propre visibility, favoris est un filtre transverse orthogonal). Filtres optionnels en plus (ListRecipesFilters) : search, suitableForHousehold (recette qui évite tous les allergènes déclarés du foyer et respecte le régime de chaque membre qui en a un — calculé serveur-side, jamais exposé en données brutes par membre : les allergies/régimes d'un membre restent privés, même logique que la visibilité 404-jamais-403), ingredientIds/dietIds (ET logique — la recette doit porter chacun, pas au moins un).

Filtrage par sources activées (sourceVisibilityWhere) — appliqué à chaque onglet : une recette manuelle (sourceId null) est toujours visible, seule une recette issue d'une source externe non activée pour le foyer du viewer est masquée. Sans foyer, rien n'est activé par construction (pas de ligne HouseSource à référencer) — toute recette sourcée est invisible tant que le profil n'a pas rejoint/créé de foyer.

Détection des techniques — tech-step-matcher.ts

Historiquement une table TechStepMapping de regex par technique/locale (weight pour départager les chevauchements) — remplacée par un pipeline node-nlp (TechStepClassifierService) une fois constaté que les regex ne généralisaient jamais au-delà de leur propre vocabulaire : une étape décrivant la fonte du beurre comme "jusqu'à ce que le beurre ait disparu dans la poêle" ne contient aucun verbe sur lequel une regex pourrait s'ancrer, alors que le sens est sans ambiguïté. TechStepMapping a été supprimée (migration 20260821130000_drop_tech_step_mapping) — plus aucune table n'est interrogée/éditée à l'exécution, les données de matching vivent en code (tech-step-training-data.ts).

node-nlp a ensuite été remplacé à son tour par un microservice Python dédié, services/tech-step-intent-service (spaCy — PhraseMatcher + textcat), appelé en HTTP par TechStepClassifierService via IntentServiceClient (intent-service-client.ts) — node-nlp était peu maintenu et tournait in-process dans l'event loop Node ; spaCy offre un écosystème NLP plus robuste, dans un processus séparé, avec l'ambition à terme de pouvoir aussi absorber ce que fait services/tech-step-llm-worker. Ce service est entièrement autonome : TECH_STEP_TRAINING_DATA (~74 techniques) vit désormais dans son propre training_data.py, revu par PR comme le reste du code mais plus poussé par apps/api via HTTP — le service s'entraîne lui-même une seule fois, à son propre démarrage, et ne touche jamais Postgres (voir son propre README, y compris pour le temps de démarrage — plusieurs minutes, l'entraînement n'étant jamais persisté sur disque).

normalizeText (décomposition NFD + suppression des diacritiques + minuscule) reste utilisée par ingredient-matcher.ts, mais n'intervient plus dans la détection des techniques elle-même — un port Python de cette même fonction (intent_service/text_normalization.py) alimente le composant de normalisation du pipeline spaCy côté service.

Pipeline en 3 étapes (TechStepClassifierService.matchTechStepSpans) :

  1. NER (le PhraseMatcher du service, construit depuis les synonyms de TECH_STEP_TRAINING_DATA) trouve chaque mention candidate d'une technique dans la description entière, avec sa position exacte — équivalent mécanique des anciennes regex, en listes de synonymes plutôt qu'en patterns écrits à la main. Le matching se fait sur une normalisation stricte (accents/casse) sans tolérance floue de type Levenshtein — voir services/tech-step-intent-service/intent_service/locale_pipeline.py.
  2. La description est découpée en clauses autour de ces candidats (splitIntoClauses, pure/testable sans modèle) — une étape nommant deux techniques a besoin que chacune soit jugée sur son propre contexte, pas la phrase entière classée d'un bloc.
  3. Classification d'intention NLP (le textcat du service, entraîné sur les utterances de TECH_STEP_TRAINING_DATA) classe chaque clause individuellement — c'est ce qui apporte la compréhension du sens : le corpus d'entraînement mélange volontairement des tournures ancrées sur le mot-clé et des paraphrases qui ne l'emploient jamais (ex. "jusqu'à ce que le beurre ait disparu" pour melt), donc le verdict final d'une clause vient de ce que le modèle reconnaît comme signifiant la technique, pas du mot littéral qui a déclenché son découpage. En dessous de CONFIDENCE_THRESHOLD (voir la constante dans tech-step-matcher.ts pour la valeur courante et comment elle a été calibrée), retombe sur la technique impliquée par l'ancre NER de la clause plutôt que d'abandonner un match clairement ancré sur un mot-clé juste parce que le modèle n'est pas assez confiant.

Résolution TechStep.key -> id mémoïsée une seule fois sur le singleton partagé techStepClassifier (jamais par requête) — c'est tout ce qu'apps/api a encore à mémoïser, l'entraînement du modèle lui-même vivant entièrement côté services/tech-step-intent-service. server.ts appelle techStepClassifier.warmUp() avant d'accepter du trafic, avec retry/backoff si services/tech-step-intent-service n'est pas encore joignable (le cas normal en Docker Compose, où app attend qu'il soit healthy avant même de démarrer — voir docker-compose.yml, et le README de ce service pour combien de temps ça prend).

Pièges rencontrés en construisant ce pipeline, tous corrigés dans le code (pas juste contournés) :

  • db/prisma.ts construisait new PrismaClient() sans jamais importer config/env.ts — dans le run de test complet, un autre fichier chargeait toujours config/env.ts (donc .env.test) en premier par pur hasard d'ordre de résolution des modules ; lancer un seul fichier de test isolément pouvait faire gagner la course au chargement .env interne de Prisma (le chemin .env de dev, baké dans le client généré) — silencieux tant que resetDatabase() ne throw pas (heureusement son garde-fou le fait). Fixé en import config/env.js pour effet de bord tout en haut de prisma.ts, avant new PrismaClient().
  • (historique, node-nlp) NlpManager avait autoSave/autoLoad: true par défaut — persistait le modèle entraîné dans un fichier model.nlp (cwd du process) et le rechargeait au lieu de ré-entraîner au prochain démarrage s'il existait déjà. Un modèle obsolète sur disque aurait masqué silencieusement toute mise à jour du corpus. Non applicable au service Python actuel : il réentraîne tout en mémoire à chaque démarrage du process, sans jamais rien persister sur disque (voir ce service's own README).
  • nlp.make_doc() (spaCy) ne fait tourner que le tokenizer, pas les composants du pipeline — un piège trouvé en construisant le PhraseMatcher du nouveau service : les patterns de synonymes doivent explicitement repasser par le composant de normalisation, sinon un synonyme accentué ("préchauffer") ne matche jamais sa forme normalisée dans le texte cible (voir le commentaire dans locale_pipeline.py's train()).

Résolution ingrédients/unités — ingredient-matcher.ts

Anglais uniquement aujourd'hui (commit "matching anglais pour les tech steps et les ingrédients") :

  • matchIngredientName tokenise (minuscule + suppression diacritiques + découpage sur non-lettres + un "stemming" naïf de pluriel, pas un vrai stemmer linguistique) le texte libre et chaque libellé du catalogue (INGREDIENT_LABELS_EN, packages/shared), cherche chaque libellé comme sous-séquence contiguë de tokens dans le nom — le plus long (le plus spécifique) l'emporte ("chicken breast" bat "chicken"), égalité départagée par l'id le plus petit (déterminisme).
  • matchUnit compare des tokens entiers (pas de sous-chaîne — "cup" ne doit pas matcher à l'intérieur d'un mot plus long sans rapport).
  • extractQuantity extrait un nombre/une fraction/un nombre mixte en tête de texte libre ("1 1/2" → 1.5) si la source n'a pas fourni de quantité structurée.

Orchestration — recipe-translation.ts

translateRecipe(recipe, locale) — matche toujours les techniques contre locale, mais ne charge/matche les catalogues ingrédient/unité que si locale === "en" ; pour toute autre langue, ingredientId/unitId restent null sur chaque ligne (dégradation "pas de données dans cette langue"). Piège documenté explicitement : une source anglaise (TheMealDB) traduite contre les mappings "fr" obtiendrait un techStepIds vide sur chaque étape (aucune règle de mapping anglaise n'existe encore) — cette étape n'invente rien. sources.service.ts's previewSourceItem (brouillon, non sauvegardé) et recipe.service.ts's createRecipe/updateRecipe/ createImportedRecipe (sauvegarde réelle) partagent exactement les mêmes briques.


Isolation de la base de test

pnpm test (apps/api) exécute une TRUNCATE ... CASCADE sur presque tout le schéma avant chaque test (test-support/reset-db.ts's resetDatabase()) — auparavant partagée avec la base de dev via un seul .env, ce qui a un jour vidé un vrai foyer/compte de dev en cours de test (irrécupérable, TRUNCATE, pas de sauvegarde). Fix, trois pièces :

  1. config/env.ts charge .env.test au lieu de .env quand NODE_ENV=test (posé par cross-env dans le script "test" de apps/api/package.json, avant que ce module ne s'exécute).
  2. resetDatabase() appelle désormais assertRunningAgainstTestDatabase() en tout premier : lève si NODE_ENV !== "test", et si DATABASE_URL ne contient ni "test" ni "ci" — la branche "ci" a dû être ajoutée après coup, la base de CI s'appelant batchcooking_ci (pas batchcooking_test), ce que le garde-fou initial rejetait à tort (282 tests en échec). Le seul nom que ce garde-fou doit encore rejeter est la vraie base de dev, batchcooking.
  3. Nouveaux fichiers apps/api/.env.test (local, gitignored) et .env.test.example (committé, template + commandes pour créer la base) — même serveur/identifiants Postgres que .env, juste un nom de base différent.

À faire une fois par machine avant pnpm test : créer apps/api/.env.test pointant vers une base différente (ex. batchcooking_test) — voir .env.test.example pour les commandes exactes (CREATE DATABASE, prisma migrate deploy). Ce fichier n'est pas créé automatiquement, contrairement à .env.

En CI (.github/workflows/ci.yml), le job test provisionne son propre service postgres:16-alpine (POSTGRES_DB: batchcooking_ci) et définit DATABASE_URL au niveau du workflow — exactement le cas que la branche "ci" du garde-fou existe pour accepter.


Compte — suppression, pas encore de changement d'email/mot de passe

DELETE /auth/me (auth.routes.ts, requireAuth) — supprime définitivement le profil courant après revérification du mot de passe (deleteAccountSchema, packages/shared/src/schemas/account.ts) : 401 INVALID_CREDENTIALS si le mot de passe ne correspond pas (argon2.verify), sinon leaveCurrentHouse (gère transfert d'adminship/suppression du foyer, voir plus haut) puis suppression du profil (cascade UserProfileAllergy via onDelete: Cascade). Aucune route de changement d'email/mot de passe n'existe encore — AccountSettingsPage (web) n'affiche l'identité qu'en lecture seule.

UserProfile.tokenVersion (bump prévu pour invalider les JWT déjà émis, ex. à un futur changement de mot de passe) existe déjà dans le schéma et est vérifié à chaque requête par requireAuth, mais rien ne l'incrémente encore — c'est une infrastructure posée à l'avance, pas encore câblée à une fonctionnalité réelle.


Pas de fichiers .d.ts écrits à la main

Voir frontend-architecture.md 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.