batchCooking/specs/batch-cooking-modele.md
kyuno053 0e0fd81563
feat(api): remplace la détection des tech steps par un pipeline NLP (node-nlp) (#63)
* feat(api): remplace la détection des tech steps par un pipeline NLP (node-nlp)

Le matching par regex ne généralisait jamais au-delà de son 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é.

Nouveau pipeline en 3 étapes (TechStepClassifierService, node-nlp
4.27.0 — la 5.x est encore alpha, non retenue) :
1. NER (entités enum) trouve les mentions candidates + leur position
   exacte, à partir de listes de synonymes (tech-step-training-data.ts)
   plutôt que de regex écrites à la main. ner.threshold: 1 (exact,
   après normalisation) — le défaut à 0.8 faisait matcher "faire" (verbe
   auxiliaire omniprésent) contre "frire" par pure proximité de chaîne.
2. La description est découpée en clauses autour de ces candidats
   (splitIntoClauses, pure/testable sans modèle).
3. Le NlpManager classe chaque clause individuellement, entraîné sur
   des phrases qui n'emploient jamais le verbe de la technique — c'est
   ce qui apporte la compréhension du sens. En dessous de
   CONFIDENCE_THRESHOLD (0.65, ajusté empiriquement), retombe sur la
   technique impliquée par l'ancre NER plutôt que d'abandonner un match
   clairement ancré sur un mot-clé.

TechStepMapping (table de regex par technique/locale) supprimée —
migration 20260821130000_drop_tech_step_mapping — plus aucune table
n'est interrogée à l'exécution, les données de matching vivent en code.
TECH_STEPS (reference-seed-data.ts) simplifié en simple liste de uid,
les mappings ayant disparu.

Deux pièges trouvés en construisant ce pipeline, corrigés à la source :
- db/prisma.ts construisait PrismaClient sans importer config/env.ts —
  un run de test isolé pouvait faire gagner la course au .env interne
  de Prisma (dev) contre .env.test. Fixé en important config/env.js en
  tout premier, pour effet de bord.
- NlpManager a autoSave/autoLoad: true par défaut — persiste le modèle
  entraîné dans model.nlp et le recharge au lieu de ré-entraîner au
  prochain démarrage. Les deux désactivés explicitement (sinon un
  modèle obsolète masquerait silencieusement toute mise à jour du
  corpus/seuil) ; model.nlp ajouté au .gitignore en garde-fou.

apps/api/src/db/prisma.ts, recipe.service.ts, sources.service.ts et
recipe-translation.ts adaptés à la matching async (le classifieur
entraîné remplace le couple loadTechStepMappingRules+matchTechStepSpans
synchrone) ; server.ts appelle techStepClassifier.warmUp() avant
d'accepter du trafic (le tout premier appel réel à
NlpManager.process() charge les ressources par langue de node-nlp,
plusieurs secondes).

Vérifié : tsc --noEmit, biome check (0 erreur), build complet des 6
packages, 308 tests API (dont un test-support/reset-db.ts corrigé —
référençait encore tech_step_mapping dans son TRUNCATE).

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

* feat(api): ajoute la délimitation de contexte aux tech steps et étoffe le vocabulaire du classifieur

Deux évolutions du pipeline NLP de détection des tech steps (PR #63) :

1. Délimitation de contexte — en plus du mot-clé qui déclenche un match
   (start/end), chaque TechStepMatch porte maintenant contextStart/
   contextEnd : la clause complète autour du mot-clé (ex : "poêle chaude"
   comme mot-clé, "Dans une poêle chaude" comme contexte). Persisté sur
   StepTechStep (colonnes nullables, migration dédiée), exposé via
   StepTechStepView, et rendu côté web avec un style plus discret que le
   mot-clé (StepDescription.tsx, .step-tech-step-context). splitIntoClauses
   coupe désormais sur l'espace le plus proche du milieu de l'écart entre
   deux candidats plutôt que sur le milieu brut, pour ne jamais couper un
   mot en deux (findGapSplitPoint).

2. Vocabulaire du classifieur — synonymes et locutions supplémentaires par
   technique (FR/EN) pour fiabiliser la détection sur des formulations que
   le corpus initial ne couvrait pas. Plusieurs bugs de fond trouvés et
   corrigés en cours de route, tous confirmés par la suite de tests
   complète (309 tests) :
   - un synonyme multi-mots qui est un préfixe-mot d'un synonyme plus court
     déjà enregistré pour la même technique fait matcher les deux comme
     candidats NER distincts et chevauchants, corrompant le découpage en
     clauses (parfois jusqu'à une mauvaise classification) — retiré
     partout où ce motif a été repéré (cook, fry, deglaze, simmer, boil,
     roast, chop, mince, marinate, preheat, bake, plate, coat) ;
   - "poêlé"/"poêlée" comme synonymes de panFry sont réduits à la même
     racine que le nom "poêle" par le stemmer français de node-nlp,
     provoquant un faux positif sur toute mention nue de "poêle" (dont
     celle de preheat) — retiré ;
   - "Fouetter les blancs en neige" était mal classé en foldIn (la phrase
     d'entraînement de foldIn partage la même locution) — corrigé en
     ajoutant des phrases d'entraînement dédiées à whisk ;
   - "Émincer les tomates" est passé sous le seuil de confiance vers melt
     après l'ajout du nouveau vocabulaire ailleurs dans le corpus — corrigé
     en élargissant les phrases d'entraînement de mince à un autre légume.

Le test unitaire de splitIntoClauses avec un point de coupure obsolète
(pré-datant findGapSplitPoint) est aussi corrigé.

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

* fix(api): corrige la découpe des clauses et le seuil de confiance du classifieur de tech steps

Trouvé en examinant des vraies recettes déjà en base après le dernier
étoffement du vocabulaire : plusieurs étapes bien réelles se faisaient
classer sur la mauvaise technique, sans lien avec un mot-clé manquant.

- splitIntoClauses coupe désormais sur la limite de phrase (juste après
  un ".", "!" ou "?") la plus proche du milieu de l'écart entre deux
  candidats quand il y en a une, plutôt que sur l'espace brut le plus
  proche du milieu. Une description à deux techniques dans deux phrases
  distinctes ("Préchauffer le four à 180°C. Dans un saladier, mettre le
  beurre... et mélanger.") ne coupait qu'au milieu brut, ce qui pouvait
  trancher en pleine deuxième phrase et envoyer au classifieur une
  clause tronquée ("...(thermostat 6). Dans un saladier, mettre" sans
  complément) — assez éloignée des phrases d'entraînement courtes et
  complètes pour se faire mal classer avec confiance (préchauffer prédit
  "mix", mélanger prédit "melt").
- CONFIDENCE_THRESHOLD passe de 0.65 à 0.75 : du texte anglais passé
  dans le classifieur français (qui doit ne rien trouver, garanti par
  le test d'isolation des locales) scorait 0.69 sur "boil" — du bruit
  de petit corpus, pas un vrai verdict. Les cas réels que ce seuil sert
  à faire confiance scorent 0.91 à 1.0 en pratique ; 0.75 sépare
  proprement le bruit du signal sans rien casser (309 tests toujours
  verts).
- Deux phrases d'entraînement ajoutées à `cook` pour deux clauses
  réelles mal classées (feu doux + remuant, découvert + laisser cuire)
  qui n'avaient pourtant pas de mot-clé manquant.

Ajoute aussi src/scripts/backfill-tech-steps.ts : la détection ne
tourne qu'à la création/modification d'une recette, jamais
rétroactivement — ce script recalcule le start/end/contextStart/
contextEnd de chaque étape existante contre le classifieur actuel,
pour ne pas avoir à rouvrir et resauvegarder chaque recette à la main
après un changement de corpus.

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

* fix(web): rend le contexte des tech steps réellement visible

Le style existant (fond teinté à 6% d'opacité, sans autre indice
visuel) était structurellement correct — vérifié en base, l'API et le
DOM contenaient bien les spans de contexte — mais imperceptible à
l'œil sur ce thème sombre : --color-primary n'est pas assez saturé
pour qu'une teinte de quelques % se distingue du fond de la carte.
Vérifié en créant une recette test dans le navigateur et en zoomant le
texte rendu : littéralement aucune différence visible avant, un
rectangle net après.

Passe à 10% de fond + une bordure basse pleine à 45% d'opacité comme
second indice visuel indépendant, tout en gardant le mot-clé
(soulignement pointillé + fond à 14% + curseur + tooltip) nettement
plus marqué que son contexte.

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

* fix(web): retire l'affichage visuel du contexte des tech steps

Ne touche que le rendu — le backend continue de calculer et de
persister contextStart/contextEnd (tech-step-matcher.ts, StepTechStep),
et splitDescriptionByTechSteps continue de découper la description
autour du contexte (segments isKeyword: false). StepDescription.tsx
rend désormais ces segments comme du texte brut, comme un segment sans
technique — plus d'encadré/bordure autour de la clause, seul le
mot-clé reste surligné avec sa tooltip.

.step-tech-step-context (CSS) retirée, devenue inutilisée.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 00:17:39 +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, entraînant un classifieur node-nlp) vivent en code (tech-step-training-data.ts), pas dans une table — 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é.