batchCooking/specs/batch-cooking-modele.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

16 KiB

Modèle de données — Projet Batch-cooking

Documentation du schéma de données de l'application de planification de batch-cooking. Source de vérité : apps/api/prisma/schema.prisma (abondamment commenté en anglais, chaque écart avec ce document ou avec le doc spec d'origine y est expliqué en place — ce fichier en est un résumé navigable, pas un remplacement).


Vue d'ensemble

Le modèle s'articule désormais autour de cinq grands pôles (le pôle « Recettes » a beaucoup grossi depuis la première version : sources externes, catalogue d'ingrédients/unités normalisé, techniques détectées, visibilité) :

  • Utilisateurs & foyerUserProfile, House, Diet, Category, Allergy, UserPreference (thème)
  • PlanificationPlanning, PlanningItem
  • RecettesRecipe, RecipeIngredient, Step, TechStep, StepTechStep, RecipeDiet, RecipeFavorite
  • Sources externesSource, HouseSource
  • Catalogue ingrédients/unitésIngredient, Unit, IngredientDiet, IngredientAllergy, UserProfileDislikedIngredient

Schéma entité-relation

erDiagram
  USER_PROFILE }o--o| HOUSE : "membre de"
  HOUSE ||--|| USER_PROFILE : "admin (adminId)"
  USER_PROFILE }o--o| DIET : "suit"
  USER_PROFILE ||--o| USER_PREFERENCE : "thème"
  HOUSE ||--o{ PLANNING : "planifie"
  PLANNING ||--o{ PLANNING_ITEM : "contient"
  PLANNING_ITEM }o--|| RECIPE : "utilise"
  RECIPE }o--o| SOURCE : "vient de"
  RECIPE }o--|| USER_PROFILE : "auteur (authorId)"
  RECIPE }o--o| HOUSE : "foyer auteur (authorHouseId)"
  HOUSE ||--o{ HOUSE_SOURCE : "sources activées"
  SOURCE ||--o{ HOUSE_SOURCE : ""
  CATEGORY ||--o{ ALLERGY : "classe"
  USER_PROFILE }o--o{ ALLERGY : "a"
  USER_PROFILE }o--o{ RECIPE : "favoris"
  USER_PROFILE }o--o{ INGREDIENT : "n'aime pas"
  RECIPE ||--o{ RECIPE_INGREDIENT : "compose de"
  RECIPE_INGREDIENT }o--|| INGREDIENT : ""
  RECIPE_INGREDIENT }o--|| UNIT : ""
  RECIPE }o--o{ DIET : "tags régime (RecipeDiet)"
  INGREDIENT }o--o{ DIET : "compatible avec"
  INGREDIENT }o--o{ ALLERGY : "contient"
  RECIPE ||--o{ STEP : "compose de"
  STEP }o--o{ TECH_STEP : "techniques détectées (StepTechStep)"
  TECH_STEP ||--o{ TECH_STEP_MAPPING : "règles de détection"

  USER_PROFILE {
    int id PK
    string firstName
    string lastName
    string email UK
    string passwordHash
    int tokenVersion
    int houseId FK
    int dietId FK
  }
  HOUSE {
    int id PK
    string name
    int adminId FK
    string inviteCode UK
  }
  USER_PREFERENCE {
    int userProfileId PK_FK
    enum theme "LIGHT | DARK | SYSTEM"
  }
  DIET {
    int id PK
    string key UK
  }
  ALLERGY {
    int id PK
    int categoryId FK
  }
  CATEGORY {
    int id PK
    string key UK
    enum kind "ALLERGY | INTOLERANCE"
  }
  PLANNING {
    int id PK
    date startDate
    date finishDate
    int houseId FK
  }
  PLANNING_ITEM {
    int id PK
    int planningId FK
    string weekDay
    string meal
    int recipeId FK
    int portions
  }
  SOURCE {
    int id PK
    string key UK
    string name
    string url
    bool official
    string iconUrl
  }
  HOUSE_SOURCE {
    int houseId PK_FK
    int sourceId PK_FK
  }
  RECIPE {
    int id PK
    string name
    int sourceId FK
    string externalId
    string description
    string picture
    int portions
    int authorId FK
    int authorHouseId FK
    enum visibility "PERSONAL | HOUSE | PUBLIC"
  }
  RECIPE_INGREDIENT {
    int recipeId PK_FK
    int ingredientId PK_FK
    decimal quantity
    int unitId FK
  }
  RECIPE_DIET {
    int recipeId PK_FK
    int dietId PK_FK
  }
  RECIPE_FAVORITE {
    int userProfileId PK_FK
    int recipeId PK_FK
  }
  INGREDIENT {
    int id PK
    string key UK
    enum icon
    enum category
    enum subcategory
    bool reproducible
  }
  UNIT {
    int id PK
    string key UK
    enum type "MASS | VOLUME | COUNT"
    decimal toBaseFactor
  }
  STEP {
    int id PK
    int recipeId FK
    string description
    string picture
    int order
  }
  TECH_STEP {
    int id PK
    string key UK
  }
  TECH_STEP_MAPPING {
    int id PK
    int techStepId FK
    string locale
    string expression
    int weight
  }
  STEP_TECH_STEP {
    int stepId PK_FK
    int techStepId PK_FK
    int order PK
    int start
    int end
  }

(Rendu sur les visualiseurs markdown compatibles mermaid — GitHub, VS Code, Obsidian, etc. Champ PK_FK = à la fois clé primaire et clé étrangère, UK = unique.)


Utilisateurs & foyer

user_profiles (UserProfile)

Champ Description
id Identifiant
firstName / lastName Nom
email Unique
passwordHash Hash argon2 du mot de passe
tokenVersion Compteur incrémenté pour invalider les JWT déjà émis (ex. changement de mot de passe) — mécanisme provisionné, aucun code ne l'incrémente encore aujourd'hui (pas de changement de mot de passe implémenté, voir backend-architecture.md)
houseId FK → house, nullable
dietId FK → diet, nullable

Relations supplémentaires par rapport au schéma d'origine : dislikedIngredients (m2m vers Ingredient, préférence de goût — voir plus bas), authoredRecipes, favoriteRecipes, administeredHouses (le foyer dont ce profil est admin, au plus un dans les faits), preferences (1-1 vers UserPreference).

house (House)

Champ Description
id Identifiant
name Nom du foyer
adminId FK → user_profiles — le membre qui administre ce foyer (créateur, ou héritier d'adminship si l'admin précédent est parti — voir backend-architecture.md)
inviteCode Code à 8 caractères, unique, généré pour rejoindre le foyer (POST /house/join)

user_preference (UserPreference)

1-1 avec user_profiles (userProfileId est à la fois clé primaire et clé étrangère — un profil a au plus une ligne). theme (LIGHT / DARK / SYSTEM, défaut SYSTEM) — préférence d'affichage, créée à la demande (pas au signup), même logique "absence = valeur par défaut" que dietId/allergies. Voir frontend-architecture.md.

diet (Diet)

Champ Description
id Identifiant
key Slug anglais stable, unique (ex. "vegetarian") — le libellé affiché vit dans apps/web/src/locales/fr/translation.json sous catalog.diets.<key>, jamais dans cette table

category / allergy (Category, Allergy)

Un allergène = une Category (le nom réel, key unique) + une Allergy (juste un id + FK vers sa catégorie). Category.kind (ALLERGY | INTOLERANCE) distingue réaction immunitaire classique de réaction non-immunitaire (seuls Gluten et Sulfites sont INTOLERANCE). 14 allergènes seedés (règlement UE 1169/2011, annexe II).


Planification

planning (Planning)

Champ Description
id Identifiant
startDate / finishDate Lundi → dimanche de la semaine couverte
houseId FK → house

Une ligne par semaine et par foyer, créée à la demande (findOrCreatePlanningForWeek, voir backend-architecture.md), pas en avance.

planning_item (PlanningItem)

Champ Description
id Identifiant
planningId FK → planning
weekDay Jour de la semaine
meal Repas concerné
recipeId FK → recipe (pas de cascade — voir RECIPE_IN_USE dans error-handling.md)
portions Nombre de portions pour ce créneau précis — indépendant de Recipe.portions (le rendement "tel qu'écrit" de la recette) : un créneau peut mettre l'échelle à la hausse/baisse. Le picker web pré-remplit depuis Recipe.portions mais stocke une valeur propre

Recettes

sources (Source) et house_source (HouseSource)

Source est le catalogue des sources d'import concrètes (un site/une API par ligne), synchronisé automatiquement depuis le registre d'adaptateurs en code (recipe-source-registry.ts) plutôt que maintenu à la main — voir backend-architecture.md.

Champ Description
id Identifiant
key Slug stable, unique — doit correspondre à RecipeSourceAdapter.key
name Nom affiché
url Optionnel
official API officielle vs scraping non-officiel
iconUrl Logo/favicon, optionnel

HouseSource (houseId, sourceId, clé composite) : quelles sources un foyer a choisi d'activer — opt-in, aucune ligne = désactivé. Un nouveau foyer démarre sans aucune source activée.

recipe (Recipe)

Champ Description
id Identifiant
name Nom de la recette
sourceId FK → sources, nullable (null = recette créée à la main)
externalId Identifiant côté source, nullable — avec sourceId, @@unique([sourceId, externalId]) empêche d'importer deux fois le même item (Postgres traite chaque NULL comme distinct, donc les recettes manuelles ne se percutent jamais entre elles)
description / picture Optionnels
portions Rendement "tel qu'écrit" par la recette
authorId FK → user_profiles — requis dès qu'une recette porte un niveau de visibilité
authorHouseId FK → house, nullable — instantané du foyer de l'auteur au moment de la création (comme Planning.houseId), ne suit pas l'auteur s'il change de foyer ensuite
visibility PERSONAL (auteur seul) / HOUSE (membres de authorHouseId) / PUBLIC (tout utilisateur connecté) — contrôle uniquement la lecture, jamais l'édition (toujours réservée à l'auteur, voir NOT_RECIPE_AUTHOR)

Relations : ingredients (RecipeIngredient, m2m avec quantité/unité), steps (Step, one-to-many — pas many-to-many comme documenté dans le doc spec d'origine : order n'a de sens que dans le cadre d'une seule recette), favoritedBy (RecipeFavorite), diets (RecipeDiet, tags manuels, pas calculés depuis les ingrédients).

ingredients (Ingredient) et catalogue associé

Table de référence (seedée, jamais créée/éditée/supprimée via l'API), comme Diet/Allergy.

Champ Description
id Identifiant
key Slug unique — libellé dans catalog.ingredients.<key> (locale)
icon IngredientIcon — ~20 pictogrammes génériques par type de chose (légume, bouteille d'huile, fromage…), pas un emoji par ingrédient (437 rejetés comme peu pro) — voir apps/web/src/features/recipes/ingredients/ingredient-icons.tsx
category / subcategory IngredientCategory (7 rayons) / IngredientSubcategory (racks plus fins) — organisation "rayon de supermarché français" pour permettre le parcours par catégorie dans le picker (400+ ingrédients, la recherche seule ne suffit pas)
reproducible Vrai si raisonnablement faisable maison (un pain burger, une béchamel) plutôt qu'un achat systématique — juste un flag, pas un lien vers une recette précise (un ancien alternateRecipeId jamais câblé a été retiré)

IngredientDiet (m2m, régimes compatibles — omet volontairement Omnivore et Sans gluten, ce dernier dérivable de IngredientAllergy) et IngredientAllergy (m2m, allergènes contenus) complètent le catalogue. UserProfileDislikedIngredient (m2m profil ↔ ingrédient) est une préférence de goût personnelle, pas médicale — jamais un avertissement de sécurité, juste un rappel sur la fiche recette (RecipeDetailPanel).

unit (Unit)

Catalogue normalisé et fini des unités de mesure — remplace l'ancien texte libre ("g", "grammes", "G"…) qui ne pouvait jamais être sommé de façon fiable.

Champ Description
id Identifiant
key Slug unique (ex. "tablespoon") — libellé dans catalog.units.<key>
type MASS / VOLUME / COUNT — seules deux unités du même type sont mutuellement convertibles
toBaseFactor Combien d'unités de base (gramme pour MASS, millilitre pour VOLUME, elle-même pour COUNT) équivaut une unité de ce type — pose les bases d'une future conversion (ex. sommer "500g" + "0.5kg"), pas encore construite

step (Step) et techniques détectées

Champ Description
id Identifiant
recipeId FK → recipe
description Texte de l'étape
picture Optionnel
order Position dans la recette

tech_step (TechStep, key unique, ex. "simmer") est le catalogue des techniques (mijoter, préchauffer…) — juste un id/clé stable référencé par step_tech_step. Les données de détection elles-mêmes (synonymes + phrases d'exemple par langue) vivent en code dans le microservice spaCy lui-même (services/tech-step-intent-service/intent_service/training_data.py), pas dans une table ni côté apps/api — l'ancienne tech_step_mapping (TechStepMapping, une regex par technique/locale) a été supprimée une fois constaté que les regex ne généralisaient jamais au-delà de leur propre vocabulaire — voir backend-architecture.md.

step_tech_step (StepTechStep) est la séquence ordonnée des techniques détectées pour une étape — une instruction peut en impliquer plusieurs (ex. "faire chauffer une noix de beurre" = preheat + melt), d'où une table de liaison avec order plutôt qu'un simple FK nullable Step.techStepId (remplacé suite à une revue de code). start/end (nullable, non rétro-remplis — une ligne d'avant l'ajout de ces colonnes n'a simplement pas de surlignage tant que sa recette n'est pas resauvegardée) sont le span détecté dans Step.description, utilisé pour le surlignage côté web (highlight-tech-steps.ts).


Relations

Many-to-one (clés étrangères)

Table source Champ FK Table cible
user_profiles houseId house
user_profiles dietId diet
house adminId user_profiles
planning houseId house
planning_item planningId planning
planning_item recipeId recipe
allergy categoryId category
recipe sourceId sources
recipe authorId user_profiles
recipe authorHouseId house
step recipeId recipe

Many-to-many (tables de jointure explicites, avec ou sans champ additionnel)

Table A Table B Table de jointure Détail
user_profiles allergy user_profile_allergy Simple
user_profiles ingredients user_profile_disliked_ingredient Simple — préférence de goût
user_profiles recipe recipe_favorite Simple
house sources house_source Simple — opt-in
recipe ingredients recipe_ingredient Porte quantity + unitId
recipe diet recipe_diet Simple — tags manuels
ingredients diet ingredient_diet Simple
ingredients allergy ingredient_allergy Simple
step tech_step step_tech_step Porte order + start/end (span détecté)

Règles de modélisation

  • Toute relation qualifiée d'« association » entre deux tables est une relation many-to-many.
  • category, diet, ingredients, unit, tech_step, sources sont des tables de référence/énumération : seedées (apps/api/src/db/reference-seed-data.ts), jamais créées/éditées/supprimées via l'API applicative. key/name y sont @unique pour permettre un seed idempotent (upsert).
  • Chaque écart avec le document de conception d'origine (colonnes ajoutées, cardinalité changée, table nouvelle) est expliqué en commentaire directement dans schema.prisma, à l'endroit concerné — s'y référer en cas de doute, ce document est un résumé, pas la source de vérité.