batchCooking/apps/api/prisma/schema.prisma
Nicolas 53d415fddb feat(tech-steps): fiabilise la detection des tech steps (corpus + LLM + corrections utilisateur)
Une seule feature livree en une seule PR, en 5 phases :

- Phase 1 : enrichit le corpus NLP (tech-step-training-data.ts) et ajoute
  un harness d'evaluation (precision/rappel/F1) avec un jeu de test etiquete
  - la premiere metrique objective de qualite pour ce classifieur.
- Phase 2 : schema Prisma (StepTechStepCorrection, TechStepTrainingSuggestion)
  + endpoints utilisateur (POST/GET corrections, ouverts a tout viewer, pas
  seulement l'auteur) + endpoints internes /internal/tech-steps/* proteges
  par secret partage (requireInternalWorker).
- Phase 3 : UI de highlight/correction cote web (selection de texte ->
  association a une technique, ou clic sur un highlight existant pour le
  corriger/supprimer) - verifiee via Cypress (component + e2e, en Chrome
  reel).
- Phase 4 : worker LLM autonome (services/tech-step-llm-worker, hors du
  monorepo pnpm comme experiments/llm-tech-step-poc) qui audite les clauses
  a faible confiance et transforme les corrections utilisateur en
  suggestions d'entrainement, sans jamais toucher le chemin interactif.
- Phase 5 : script retrain-tech-steps.ts (gate de regression F1 + backfill)
  et list-pending-training-suggestions.ts pour la revue humaine avant
  application au corpus.

Verification effectuee cette session : tsc/biome sur l'ensemble du repo,
build complet (pnpm build), suite Cypress complete (component 39/39, e2e
75/76 - le seul echec est preexistant et sans rapport, cote
recipe-form.feature/ingredient-picker), tests unitaires du worker (6/6) et
son install/typecheck reels contre node-llama-cpp. Les tests Mocha
d'apps/api (Phases 1 et 2) n'ont pas pu etre executes dans cette session
(pas de Postgres local disponible) - a lancer avant merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 09:48:02 +02:00

799 lines
36 KiB
Text

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// -----------------------------------------------------------------------------
// Users & household
// See specs/batch-cooking-modele.md for the source data model documentation.
// -----------------------------------------------------------------------------
model House {
id Int @id @default(autoincrement())
name String
/// The member who administers this household — created it, or inherited
/// adminship when the previous admin left/deleted their account (see
/// `house.service.ts`'s `leaveCurrentHouse`). Always set: a house is
/// deleted outright once it would otherwise have no admin left.
adminId Int @map("admin_id")
/// Shareable code another user enters via `POST /house/join` to become a
/// member — see `house.service.ts`'s generator for the charset/length.
inviteCode String @unique @map("invite_code")
admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id])
members UserProfile[] @relation("HouseMember")
plannings Planning[]
/// Recipes whose author belonged to this household when they created
/// them — see `Recipe.authorHouseId`.
authoredRecipes Recipe[]
/// Which recipe sources this household sees in its recipe tabs — see `HouseSource`.
enabledSources HouseSource[]
@@map("house")
}
/// `key` is `@unique` — not in the original spec doc, added so the seed
/// script (prisma/seed.ts) can `upsert` by key and stay idempotent/safe to
/// re-run, and so two reference rows can never silently duplicate the same
/// regime. A stable English camelCase uid (e.g. `"vegetarian"`), not the
/// display label — the label itself lives in `apps/web`'s
/// `locales/fr/translation.json` under `catalog.diets.<key>` (see
/// `reference-seed-data.ts`'s `DIETS`), so it can be edited/translated
/// without ever touching this column or the rows that reference it by id.
model Diet {
id Int @id @default(autoincrement())
key String @unique
users UserProfile[]
recipes RecipeDiet[]
ingredients IngredientDiet[]
@@map("diet")
}
/// Not in the original spec doc — a category is either a true (IgE-mediated)
/// allergy or a non-immune intolerance; the UI groups selectable allergens
/// into two separate lists (`AllergySelect`, apps/web) instead of one flat
/// "allergies & intolérances" list.
enum AllergenKind {
ALLERGY
INTOLERANCE
}
/// Enumeration-style table, meant to grow over time (e.g. allergy nuances).
/// `key` is `@unique` for the same reason as `Diet.key` above — a stable
/// slug (`catalog.allergens.<key>` in `apps/web`'s locale file), not the
/// display label. `kind` is also not in the original spec doc — see
/// {@link AllergenKind}.
model Category {
id Int @id @default(autoincrement())
key String @unique
kind AllergenKind @default(ALLERGY)
allergies Allergy[]
@@map("category")
}
model Allergy {
id Int @id @default(autoincrement())
categoryId Int @map("cat_id")
category Category @relation(fields: [categoryId], references: [id])
users UserProfileAllergy[]
ingredients IngredientAllergy[]
@@map("allergy")
}
model UserProfile {
id Int @id @default(autoincrement())
firstName String @map("first_name")
lastName String @map("last_name")
email String @unique
/// argon2 hash of the account password. Not in the original spec doc —
/// added for authentication (login page / profile creation).
passwordHash String @map("password_hash")
/// Bumped to invalidate previously-issued JWTs (e.g. on password change).
/// Not in the original spec doc — required for stateless JWT auth.
tokenVersion Int @default(0) @map("token_version")
houseId Int? @map("house_id")
dietId Int? @map("diet_id")
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
allergies UserProfileAllergy[]
/// Ingredients this profile personally dislikes — a taste preference, not
/// a medical constraint (see {@link UserProfileDislikedIngredient} and
/// `allergies` above for the distinct medical list).
dislikedIngredients UserProfileDislikedIngredient[]
/// Recipes authored by this profile — see `Recipe.authorId`.
authoredRecipes Recipe[]
/// Recipes this profile has favorited — see {@link RecipeFavorite}.
favoriteRecipes RecipeFavorite[]
/// Households this profile administers. In practice at most one — a
/// profile can only ever belong to (and thus admin) a single household at
/// a time — but Prisma models the admin side of a one-to-many FK as a
/// list regardless of that real-world cardinality.
administeredHouses House[] @relation("HouseAdmin")
preferences UserPreference?
/// Tech-step corrections this profile has submitted (any profile that can
/// view a recipe may correct its tech-step matches, not just its author —
/// see `StepTechStepCorrection.correctorId`).
techStepCorrections StepTechStepCorrection[]
@@map("user_profiles")
}
/// Explicit join table for the user_profiles <-> ingredient "disliked"
/// association — same shape as `UserProfileAllergy`, but a personal taste
/// preference rather than a medical restriction: not surfaced as a safety
/// warning, just a reminder on a recipe's detail view (see
/// `RecipeView`/`RecipeDetailPanel`, apps/web).
model UserProfileDislikedIngredient {
userProfileId Int @map("user_profile_id")
ingredientId Int @map("ingredient_id")
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
@@id([userProfileId, ingredientId])
@@map("user_profile_disliked_ingredient")
}
/// Not in the original spec doc — personalization settings (theme for now,
/// meant to grow), one row per profile, created on demand (see
/// `preferences.service.ts`) rather than at signup — same "absent means the
/// default" philosophy as `dietId`/allergies.
enum ThemePreference {
LIGHT
DARK
/// Follow the OS/browser preference — the default. Not "no row yet" (that
/// case is handled in the service layer) but an explicit choice to track
/// the system, distinguishable from a user who hasn't decided yet if this
/// model ever needs that distinction.
SYSTEM
}
model UserPreference {
/// Both the primary key and the FK — a strict 1-1 with UserProfile, no
/// separate auto-incrementing id (a profile has at most one preferences row).
userProfileId Int @id @map("user_profile_id")
theme ThemePreference @default(SYSTEM)
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
@@map("user_preference")
}
/// Explicit join table for the user_profiles <-> allergy association
/// (documented in the spec as a plain many-to-many, no extra fields).
model UserProfileAllergy {
userProfileId Int @map("user_profile_id")
allergyId Int @map("allergy_id")
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
allergy Allergy @relation(fields: [allergyId], references: [id], onDelete: Cascade)
@@id([userProfileId, allergyId])
@@map("user_profile_allergy")
}
// -----------------------------------------------------------------------------
// Planning
// -----------------------------------------------------------------------------
model Planning {
id Int @id @default(autoincrement())
startDate DateTime @map("start_date") @db.Date
finishDate DateTime @map("finish_date") @db.Date
houseId Int @map("house_id")
house House @relation(fields: [houseId], references: [id], onDelete: Cascade)
items PlanningItem[]
@@map("planning")
}
model PlanningItem {
id Int @id @default(autoincrement())
planningId Int @map("planning_id")
weekDay String @map("week_day")
meal String
recipeId Int @map("recipe_id")
portions Int
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id])
@@map("planning_item")
}
// -----------------------------------------------------------------------------
// Recipes
// -----------------------------------------------------------------------------
/// Catalog of implemented recipe sources (specific websites/APIs the
/// import pipeline knows how to talk to) — one row per adapter registered
/// in `apps/api/src/lib/recipe-source-registry.ts`, kept in sync by
/// `syncRecipeSources` (`apps/api/src/db/recipe-source-sync.ts`) rather
/// than hand-maintained like `DIETS`/`UNITS` (`reference-seed-data.ts`):
/// the adapter registry is the actual source of truth for "which sources
/// exist", this table just mirrors it so `Recipe.sourceId` has something
/// to point at. `key` matches `RecipeSourceAdapter.key` — same stable
/// English camelCase uid convention as `Diet.key`/`Unit.key`/`TechStep.key`.
/// Empty until a concrete adapter is registered (none exists yet, see
/// recipe-source-adapter.ts).
model Source {
id Int @id @default(autoincrement())
key String @unique
name String
url String?
/// Whether this is an official API (the site/publisher provides
/// structured recipe data itself) or unofficial web scraping (we parse
/// HTML the site never committed to a stable shape for) — mirrors
/// `RecipeSourceAdapter.official` (recipe-source-adapter.ts), synced the
/// same way as `key`/`name`. Surfaced to households picking which
/// sources to enable (see `HouseSource`) so scraped content is never
/// mistaken for an official feed.
official Boolean
/// The source's own logo/favicon URL, shown next to its name in
/// `SourceSelect` (apps/web) — mirrors `RecipeSourceAdapter.iconUrl`,
/// synced the same way as `name`/`official`. `null` if the source has
/// none worth showing.
iconUrl String? @map("icon_url")
recipes Recipe[]
enabledHouses HouseSource[]
@@map("sources")
}
/// Which sources a household has chosen to see recipes from — opt-in: no
/// row means disabled. A newly created household starts with nothing
/// enabled (see the household-creation step in the signup wizard, and the
/// household settings page for changing this later); every recipe catalog
/// tab (`recipe.service.ts`'s `listRecipes`) filters out recipes whose
/// `sourceId` isn't in this list for the viewer's household — a
/// manually-authored recipe (`sourceId` `null`) is never affected, this
/// only ever hides recipes that came from an external source.
model HouseSource {
houseId Int @map("house_id")
sourceId Int @map("source_id")
house House @relation(fields: [houseId], references: [id], onDelete: Cascade)
source Source @relation(fields: [sourceId], references: [id], onDelete: Cascade)
@@id([houseId, sourceId])
@@map("house_source")
}
/// Not in the original spec doc — who can *read* a recipe. Controls only
/// visibility, never editing: a recipe can only ever be edited/deleted by
/// its `author`, whatever this is set to (see `recipe.service.ts`).
enum RecipeVisibility {
/// Visible to its author only.
PERSONAL
/// Visible to `authorHouseId`'s members (a snapshot of the author's
/// household *at creation time* — see `Recipe.authorHouseId`).
HOUSE
/// Visible to every signed-in user — the "shared catalog" behavior the
/// very first version of this feature shipped with.
PUBLIC
}
model Recipe {
id Int @id @default(autoincrement())
name String
sourceId Int? @map("source_id")
/// The item's identifier on `source` (`RecipeSourceListItem.externalId`,
/// recipe-source-adapter.ts) — `null` for a manually-authored recipe,
/// alongside `sourceId` being `null`. Together with `sourceId`, this is
/// what `findImportedExternalIds` (recipe-source-sync.ts) checks against
/// to tell an already-imported source item apart from a new one when
/// browsing (see `markAlreadyImported`, recipe-source-adapter.ts) — the
/// `@@unique([sourceId, externalId])` below is what actually prevents
/// importing the same source recipe twice (Postgres treats each `NULL`
/// as distinct, so manually-authored recipes never collide with each
/// other here).
externalId String? @map("external_id")
description String?
picture String?
/// How many portions this recipe yields as written (its ingredient
/// quantities/steps assume this count) — distinct from
/// `PlanningItem.portions`, which is how many to actually prepare for one
/// planning slot and now defaults to this value client-side but is still
/// entered/stored independently (a planning slot may scale the recipe
/// up/down).
portions Int
/// Creator — not in the original spec doc, required once recipes carry a
/// visibility level (`PERSONAL`/`HOUSE` need someone to scope against).
authorId Int @map("author_id")
/// The author's household *at the time this recipe was created* — a
/// snapshot (same idea as `Planning.houseId`), not a live lookup: it
/// doesn't follow the author if they later change household. `null` if
/// the author had no household yet.
authorHouseId Int? @map("author_house_id")
visibility RecipeVisibility @default(PERSONAL)
author UserProfile @relation(fields: [authorId], references: [id])
authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull)
source Source? @relation(fields: [sourceId], references: [id], onDelete: SetNull)
ingredients RecipeIngredient[]
steps Step[]
planningItems PlanningItem[]
favoritedBy RecipeFavorite[]
diets RecipeDiet[]
@@unique([sourceId, externalId])
@@map("recipe")
}
/// Explicit join table for the user_profiles <-> recipe "favorited"
/// association — same shape as `UserProfileAllergy`. Per-user, not
/// per-household: two members of the same household can favorite different
/// recipes independently.
model RecipeFavorite {
userProfileId Int @map("user_profile_id")
recipeId Int @map("recipe_id")
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
@@id([userProfileId, recipeId])
@@map("recipe_favorite")
}
/// Explicit join table for the recipe <-> diet "associated regime" tags
/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a
/// manual reminder set by whoever creates/edits the recipe, not computed
/// from its ingredients.
model RecipeDiet {
recipeId Int @map("recipe_id")
dietId Int @map("diet_id")
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
@@id([recipeId, dietId])
@@map("recipe_diet")
}
/// `key` is `@unique` — not in the original spec doc, added so the seed
/// script (reference-seed-data.ts) can `upsert` by key and stay
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
/// stable slug (`catalog.ingredients.<key>` in `apps/web`'s locale file),
/// not the display label.
/// Ingredients are reference data (like Diet/Allergy): seeded, never
/// created/edited/deleted through the API.
/// Not in the original spec doc — supermarket-aisle grouping ("rayons") so
/// the ingredient picker (apps/web) can offer category browsing, not just
/// free-text search: with 400+ reference ingredients, search alone doesn't
/// scale to actually *finding* one. Reworked from an earlier, less
/// intuitive scheme (cuisine-of-origin categories mixed in with aisle-style
/// ones, e.g. a "cuisine italienne" bucket sitting next to "légumes" —
/// meant an ingredient's category depended on which one you thought of
/// first) into how a French grocery store is actually laid out: 7 aisles,
/// each with a couple of {@link IngredientSubcategory} racks for finer
/// browsing once "Épicerie sèche" alone would be 100+ items deep. Mirrors
/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS` keys exactly — that file
/// is the single source of truth for which ingredient belongs to which
/// (category, subcategory) pair, these enums just give it type-safe
/// columns to live in. `@default(dryGoods)` exists only so this
/// column can be added `NOT NULL` to a table that may already have rows —
/// the seed script corrects every row's real category on the very next
/// run, this default is never the intended value for a real ingredient.
enum IngredientCategory {
/// 🥦 Vegetables, fruits, fresh herbs.
freshProduce
/// 🥩 Meats, poultry, fish, shellfish & seafood.
meatAndSeafood
/// 🥫 Starches, legumes, nuts & seeds, and the rest of the dry/tinned
/// goods that don't fit any other bucket (dried seaweed, dried
/// mushrooms…).
dryGoods
/// 🍞 Breads and raw dough (uncooked, ready to bake).
bakery
/// 🧈 Dairy, eggs, plant-based alternatives (plant milks, tofu…).
dairyAndCheese
/// 🧂 Spices, sauces, seasonings (oils, vinegars, cooking alcohols…).
condimentsAndSpices
/// 🍳 Prep bases (flours, stocks, water), thickeners (yeasts, starches,
/// gelatin), sugars.
cookingEssentials
}
/// Finer-grained rack within one {@link IngredientCategory} aisle — see
/// that enum's doc comment for why this two-level scheme replaced a flat
/// list. Each value belongs to exactly one category by construction (see
/// `reference-seed-data.ts`'s `INGREDIENT_GROUPS`, not enforced at the
/// database level — Postgres enums can't express that relationship, same
/// tradeoff already accepted for `IngredientCategory` itself).
/// `@default(other)` — same NOT-NULL-migration-safety-net reasoning as
/// `IngredientCategory`'s default, never the intended value for a real row.
enum IngredientSubcategory {
// --- freshProduce ----------------------------------------------------------
vegetables
fruits
freshHerbs
// --- meatAndSeafood ----------------------------------------------------------
meats
poultry
fish
shellfish
// --- dryGoods ----------------------------------------------------------------
starches
legumes
nutsAndSeeds
/// Catch-all for dried/tinned pantry items that don't fit the three
/// subcategories above — dried seaweed, dried mushrooms, tinned bamboo
/// shoots/water chestnuts…
other
// --- bakery --------------------------------------------------------------
breads
/// Raw, uncooked doughs meant to be baked (puff pastry, shortcrust…) —
/// distinct from `breads` (already-baked bread).
rawDough
// --- dairyAndCheese ------------------------------------------------------
dairy
eggs
/// Plant-based dairy/meat substitutes — coconut/almond/oat "milk", tofu.
plantBasedAlternatives
// --- condimentsAndSpices ---------------------------------------------------
spices
sauces
/// Oils, vinegars, citrus juices, cooking alcohols/wines — liquids that
/// season rather than form the base of a dish.
seasonings
// --- cookingEssentials -----------------------------------------------------
/// Flours, stocks/broths, canned tomato bases, water — the literal base
/// a recipe is built on.
bases
/// Leavening/gelling/thickening agents — yeast, baking soda, cornstarch,
/// gelatin.
thickeners
sugars
}
/// Generic pictogram *type* for an ingredient — not in the original spec
/// doc. Started as a free-text emoji column (one character per ingredient,
/// 437 different ones), which the product decision recorded in chat
/// rejected as unprofessional/inconsistent. Rather than 437 hand-drawn SVG
/// icons (unrealistic), ingredients share a small vocabulary of ~20
/// generic shapes grouped by *what kind of thing* they are — a vegetable,
/// a bottle of oil, a wedge of cheese — regardless of which specific
/// ingredient. `apps/web`'s `features/recipes/ingredient-icons.tsx` maps
/// each value to its actual SVG (matching the app's hand-drawn line-icon
/// style, never emoji — see that file for the full reasoning and the
/// exact `reference-seed-data.ts` assignment per ingredient).
/// `@default(JAR)` — same NOT-NULL-migration-safety-net reasoning as
/// `IngredientCategory`'s default, never the intended value for a real row.
enum IngredientIcon {
VEGETABLE
FRUIT
HERB
MEAT
POULTRY
FISH
SHELLFISH
GRAIN
LEGUME
NUT_SEED
BREAD
DOUGH
MILK
CHEESE
EGG
SPROUT
SPICE
JAR
BOTTLE
DRINK
STOCK_POT
SUGAR
}
model Ingredient {
id Int @id @default(autoincrement())
key String @unique
icon IngredientIcon @default(JAR)
category IngredientCategory @default(dryGoods)
subcategory IngredientSubcategory @default(other)
/// Whether this ingredient is reasonably makeable at home (a burger bun,
/// a béchamel) rather than something you'd only ever buy (a raw
/// vegetable, a specific cut of meat) — surfaced in the recipe form as a
/// badge/link nudging the author to go check the recipe catalog for a
/// "make it yourself" recipe (see `apps/web`'s `IngredientRow`/
/// `IngredientPicker`). Deliberately just a flag, not a link to a
/// specific recipe — replaces an earlier, never-wired-up
/// `alternateRecipeId` FK (product decision discussed in chat: no
/// ingredient↔recipe linking in the database, the UI only pre-fills the
/// catalog's own search with this ingredient's name).
reproducible Boolean @default(false)
recipes RecipeIngredient[]
allergies IngredientAllergy[]
/// Profiles that personally dislike this ingredient — see {@link UserProfileDislikedIngredient}.
dislikedBy UserProfileDislikedIngredient[]
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
diets IngredientDiet[]
@@map("ingredients")
}
/// Explicit join table for the ingredients <-> diet regime association —
/// which regimes (Végétarien, Végan, Pescétarien…) this ingredient is safe
/// for, so the picker (apps/web's `IngredientPicker`/`IngredientRow`) can
/// flag e.g. an ingredient as vegan without the user having to open its
/// packaging. Seeded by category in `reference-seed-data.ts` (most
/// ingredients in a category share the same compatible regimes, with
/// per-item overrides for exceptions — meat cuts, dairy, seafood…), same as
/// `IngredientAllergy`. Deliberately omits `Omnivore` (every ingredient is
/// trivially compatible — storing it would be pure noise) and `Sans gluten`
/// (already fully derivable from whether `IngredientAllergy` links this
/// ingredient to the `Gluten` allergen — a second, hand-maintained source
/// for the same fact would only risk drifting out of sync with it).
model IngredientDiet {
ingredientId Int @map("ingredient_id")
dietId Int @map("diet_id")
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
@@id([ingredientId, dietId])
@@map("ingredient_diet")
}
/// Explicit join table for the ingredients <-> allergy association — not in
/// the original spec doc, added so the recipe catalog can surface which
/// allergens an ingredient (and by extension a recipe) carries. Same shape
/// as `UserProfileAllergy`.
model IngredientAllergy {
ingredientId Int @map("ingredient_id")
allergyId Int @map("allergy_id")
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
allergy Allergy @relation(fields: [allergyId], references: [id], onDelete: Cascade)
@@id([ingredientId, allergyId])
@@map("ingredient_allergy")
}
/// Which physical quantity a {@link Unit} measures — only units of the same
/// type are ever mutually convertible via `toBaseFactor` (grams and
/// kilograms both measure MASS; a "pincée" and a "gousse" are both COUNT
/// but converting between *them* would need per-ingredient data no catalog
/// entry alone can provide, so COUNT units just don't convert to each
/// other, each stands alone with `toBaseFactor = 1`).
enum UnitType {
MASS
VOLUME
COUNT
}
/// `key` is `@unique` — same idempotent-seed/no-duplicate reasoning as
/// `Diet.key`. A stable English camelCase uid (e.g. `"tablespoon"`), not the
/// display label — the label lives in `apps/web`'s
/// `locales/fr/translation.json` under `catalog.units.<key>` (see
/// `reference-seed-data.ts`'s `UNITS`).
///
/// Not in the original spec doc — `RecipeIngredient.unit` used to be free
/// text ("g", "grammes", "G"…), which can never be reliably summed/converted
/// (a future shopping list can't tell "g" and "grammes" are the same unit).
/// This closes that off: `unit` is now a normalized, finite catalog.
/// `toBaseFactor` is how many of this type's base unit (gram for MASS,
/// milliliter for VOLUME, itself for COUNT) one of this unit equals —
/// laying the groundwork for a future conversion feature (e.g. summing
/// "500g" + "0.5kg" of the same ingredient into "1kg") without building
/// that feature itself yet.
model Unit {
id Int @id @default(autoincrement())
key String @unique
type UnitType
toBaseFactor Decimal @default(1) @map("to_base_factor") @db.Decimal(12, 4)
recipeIngredients RecipeIngredient[]
@@map("unit")
}
/// recipe <-> ingredients association. The spec documents this as a plain
/// many-to-many, but a shopping list / batch-cooking calculation needs a
/// quantity per recipe, so this join table carries quantity + unit
/// (project decision, not in the original spec doc).
model RecipeIngredient {
recipeId Int @map("recipe_id")
ingredientId Int @map("ingredient_id")
quantity Decimal @db.Decimal(10, 2)
unitId Int @map("unit_id")
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
unit Unit @relation(fields: [unitId], references: [id])
@@id([recipeId, ingredientId])
@@map("recipe_ingredient")
}
/// `key` is `@unique` — same convention as `Diet`/`Unit`: a stable English
/// camelCase uid (e.g. `"simmer"`), not the display label — the French
/// label lives in `apps/web`'s `locales/fr/translation.json` under
/// `catalog.techSteps.<key>` (see `reference-seed-data.ts`'s `TECH_STEPS`).
///
/// Matching a step's free text against these (`tech-step-matcher.ts`'s
/// `TechStepClassifierService`) used to go through a DB-backed
/// `TechStepMapping` table of per-locale regex expressions — replaced with
/// a node-nlp model trained from in-code data
/// (`tech-step-training-data.ts`) once regexes turned out unable to
/// generalize past their own literal vocabulary. Nothing queries/edits
/// that matching data at runtime anymore (it only ever feeds the
/// classifier's one-time training pass), so it no longer needs a table of
/// its own — this row now only exists to be a stable id/key other tables
/// (`StepTechStep`) reference.
model TechStep {
id Int @id @default(autoincrement())
key String @unique
steps StepTechStep[]
/// Corrections where this technique was the *previous* (possibly wrong)
/// match — see `StepTechStepCorrection.previousTechStepId`.
correctionsAsPrevious StepTechStepCorrection[] @relation("PreviousTechStep")
/// Corrections where this technique was the *corrected* (user-asserted)
/// match — see `StepTechStepCorrection.correctedTechStepId`.
correctionsAsCorrected StepTechStepCorrection[] @relation("CorrectedTechStep")
/// Training-corpus suggestions targeting this technique — see
/// `TechStepTrainingSuggestion`.
trainingSuggestions TechStepTrainingSuggestion[]
@@map("tech_step")
}
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the
/// many-to-many noted in the spec doc: `order` only makes sense scoped to a
/// single recipe, which isn't reconcilable with steps being shared across
/// recipes. See specs/batch-cooking-modele.md for the original wording.
model Step {
id Int @id @default(autoincrement())
recipeId Int @map("recipe_id")
description String
picture String?
order Int
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
techSteps StepTechStep[]
/// User-submitted corrections to this step's detected techniques — see
/// `StepTechStepCorrection`.
corrections StepTechStepCorrection[]
@@map("step")
}
/// A single step's *ordered sequence* of detected techniques — one
/// instruction can genuinely involve more than one (e.g. "Dans une poêle
/// chaude, faire chauffer une noix de beurre" is both `preheat` and
/// `melt`), which is why this replaced the original single nullable
/// `Step.techStepId` FK (per PR review feedback on the first version of
/// this feature). `order` is the position within *this step* (0-based, in
/// the order `matchTechStepSpans` — `tech-step-matcher.ts` — detected the
/// techniques in the description), not a global ordering across different
/// steps of the recipe (that's `Step.order`).
///
/// `start`/`end` are the tight matched *keyword* span within
/// `Step.description` (see `TechStepMatch`, `tech-step-matcher.ts`) — what
/// the recipe detail view highlights strongly, with a tooltip.
/// `contextStart`/`contextEnd` are the wider *clause* the keyword was found
/// in (e.g. "Dans une poêle chaude" around a `preheat` keyword of "poêle
/// chaude") — always contains `start`/`end` — what the detail view
/// highlights more subtly around it, so both "the exact trigger word(s)"
/// and "how much of the sentence is about this technique" are visible.
/// Nullable, **not backfilled**: adding them `NOT NULL` without a default
/// would fail outright against any pre-existing row, the same mistake the
/// `ingredient_unit_catalog` migration made against real prod data. A row
/// from before a column existed just has no span for it (no highlight)
/// until its recipe is next saved, which recomputes every step's
/// techniques from scratch (`recipe.service.ts`'s `updateRecipe` deletes
/// and recreates every `Step`/`StepTechStep`, never a partial patch) —
/// graceful degradation, not a permanent gap.
model StepTechStep {
stepId Int @map("step_id")
techStepId Int @map("tech_step_id")
order Int
start Int?
end Int?
contextStart Int? @map("context_start")
contextEnd Int? @map("context_end")
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
@@id([stepId, order])
@@map("step_tech_step")
}
/// One user-submitted correction to a `Step`'s detected techniques —
/// captures ADD (a missing technique the classifier didn't find),
/// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape:
/// `previousTechStepId` is the (possibly absent) match being corrected,
/// `correctedTechStepId` is what the user asserts instead (absent means
/// "no technique belongs here"). Both `null` at once is invalid (nothing
/// would have changed) — enforced service-side, not by the schema, same
/// posture as other cross-field invariants in this codebase (e.g.
/// `RecipeIngredientView`'s no-duplicate-ingredient check).
///
/// `start`/`end` are the user's selected `[start, end)` span within
/// `Step.description` (`String.prototype.slice` convention, same as
/// `StepTechStep`) — what they highlighted before assigning a technique to
/// it, not necessarily identical to any existing `StepTechStep` span.
///
/// Never edited/deleted once created (an audit trail of what was actually
/// submitted) — only `consumedAt` changes, stamped once
/// `services/tech-step-llm-worker` has turned this correction into a
/// `TechStepTrainingSuggestion` for a maintainer to review, so the same
/// correction isn't proposed twice on the next scheduled run.
model StepTechStepCorrection {
id Int @id @default(autoincrement())
stepId Int @map("step_id")
/// Any profile that could *view* the recipe when they submitted this, not
/// necessarily its author — see `assertRecipeVisible`,
/// `recipe.service.ts`.
correctorId Int @map("corrector_id")
start Int
end Int
previousTechStepId Int? @map("previous_tech_step_id")
correctedTechStepId Int? @map("corrected_tech_step_id")
createdAt DateTime @default(now()) @map("created_at")
consumedAt DateTime? @map("consumed_at")
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
corrector UserProfile @relation(fields: [correctorId], references: [id], onDelete: Cascade)
previousTechStep TechStep? @relation("PreviousTechStep", fields: [previousTechStepId], references: [id], onDelete: SetNull)
correctedTechStep TechStep? @relation("CorrectedTechStep", fields: [correctedTechStepId], references: [id], onDelete: SetNull)
trainingSuggestions TechStepTrainingSuggestion[]
@@map("step_tech_step_correction")
}
/// A candidate addition to `TECH_STEP_TRAINING_DATA`
/// (`tech-step-training-data.ts`), proposed by `services/tech-step-llm-worker`
/// from one of two sources (`sourceType`):
///
/// - `"correction"` — a user's `StepTechStepCorrection`, turned into
/// suggested synonyms/utterances by the worker's LLM
/// (`transform-corrections` job).
/// - `"llm_audit"` — a low-confidence NLP clause on an *existing* recipe the
/// worker periodically samples and re-judges with its LLM
/// (`audit-low-confidence` job); no `sourceCorrectionId` in this case.
///
/// Deliberately never auto-applied to `tech-step-training-data.ts` — a
/// maintainer reviews `status: "pending"` rows (see
/// `list-pending-training-suggestions.ts`) and edits that file by hand,
/// same "generated suggestion, human-reviewed source of truth" split as a
/// linter's autofix vs. a human-authored diff. `retrain-tech-steps.ts` then
/// flips `status` to `"applied"`/`"rejected"` once a maintainer has acted on
/// a batch, so the same suggestion isn't reviewed twice.
///
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
/// (`String[]`), not a join table — unlike this schema's other list-shaped
/// data (`RecipeDiet`, `UserProfileAllergy`...), these strings are free text
/// proposed once for a human to read, not ids referencing another catalog
/// table, so there's nothing for a join table to normalize against.
model TechStepTrainingSuggestion {
id Int @id @default(autoincrement())
techStepId Int @map("tech_step_id")
locale String
suggestedSynonyms String[] @map("suggested_synonyms")
suggestedUtterances String[] @map("suggested_utterances")
sourceType String @map("source_type")
sourceCorrectionId Int? @map("source_correction_id")
status String @default("pending")
createdAt DateTime @default(now()) @map("created_at")
techStep TechStep @relation(fields: [techStepId], references: [id])
sourceCorrection StepTechStepCorrection? @relation(fields: [sourceCorrectionId], references: [id], onDelete: SetNull)
@@map("tech_step_training_suggestion")
}