batchCooking/apps/api/src/db/reference-seed-data.ts
kyuno053 550627919d
feat(recipes): associe ingredients, quantites et ustensiles aux techniques detectees (#75)
* feat(recipes): associe ingredients, quantites et ustensiles aux techniques detectees

Etend le pipeline de detection de techniques (tech-step-matcher.ts) pour
resoudre, par clause, les metadonnees qui accompagnent une technique
detectee :

- Ingredients : nouvelle fonction findIngredientMentions (ingredient-matcher.ts)
  qui scanne le texte d'une clause contre le catalogue Ingredient existant
  (reutilise INGREDIENT_LABELS_FR/EN deja utilise par matchIngredientName),
  avec extraction best-effort de la quantite+unite immediatement avant la
  mention.
- Ustensiles : nouveau catalogue Utensil (Prisma) + second PhraseMatcher
  cote service Python (intent_service/utensil_vocabulary.py), independant
  du textcat des techniques (pas d'interpretation necessaire pour un
  ustensile). POST /v1/process distingue desormais chaque entite via un
  champ kind (technique|utensil).
- Persistance : deux nouvelles tables StepTechStepIngredient/
  StepTechStepUtensil, liees a StepTechStep par sa cle composite
  (stepId, order), peuplees au moment du matching (recipe.service.ts) et
  exposees via StepTechStepView (packages/shared).

Aucune analyse syntaxique ajoutee (le parser spaCy reste exclu du
pipeline) : l'association se fait par appartenance a la clause deja
calculee par splitIntoClauses.

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

* fix(recipes): corrige les tests casses par les nouveaux champs ingredients/utensils

recipe-tech-step-correction.test.ts asserte StepTechStepView en dur sans
les nouveaux champs ingredients/utensils (toujours [] pour une correction
manuelle, qui ne repasse jamais par le scan de metadonnees).

Retire aussi le nouveau cas de tech-step-matcher.test.ts qui inventait une
phrase jamais vue par le corpus reel : verifie en CI que le textcat la
classe avec confiance comme caramelize plutot que melt, un artefact du
petit corpus BOW plutot qu'un bug du code de matching. L'extraction
quantite+unite reste couverte integralement et de facon deterministe par
ingredient-matcher.test.ts.

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

* feat(recipes): equilibre le corpus d'entrainement du textcat a 20 phrases par technique

Chaque technique n'avait que 3 a 7 utterances par locale (moyenne ~3.8),
un desequilibre reel entre classes qui contribue directement a des
classifications confiantes mais fausses sur une formulation jamais vue
(constate concretement dans la PR precedente : une phrase inedite pour
melt classee comme caramelize avec une confiance elevee).

Porte chaque technique a exactement 20 utterances par locale (fr et en) :
- Les utterances existantes sont conservees telles quelles, jamais
  reecrites.
- Le complement vient d'augment_utterances.py (nouveau script maintainer,
  reutilisable pour une future technique sous-alimentee) : enveloppe
  chaque utterance deja a l'imperatif/infinitif dans une tournure modale
  grammaticalement valide (il faut/veillez a/make sure to...) plutot que
  de dupliquer ou d'inventer du texte generique - vraie diversite de
  surface, vocabulaire distinctif de la technique intact.
- tests/test_training_data_balance.py fait respecter l'invariant en CI
  (20 minimum, meme nombre fr/en) pour toute future modification.

_TRAINING_ITERATIONS recalibre de 25 a 10 (locale_pipeline.py) pour
compenser les ~2.6x d'exemples par epoque : temps d'entrainement mesure
quasi identique a avant (~687s fr+en combines contre ~670s), confiance
egale ou meilleure sur les cas deja suivis (simmer 0.31 -> 0.48).

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

* fix(recipes): remonte _TRAINING_ITERATIONS a 20, la gate F1 de CI etait sous 0.8 a 10

Le premier passage CI de l'equilibrage du corpus (20 utterances/technique)
a fait chuter le F1 agrege (tech-step-eval.test.ts) a 0.7999... avec
_TRAINING_ITERATIONS=10 : le pari qu'un corpus plus large convergerait en
moins d'epoques relatives etait faux a ce niveau de reduction. Remonte a
20 (mesure : ~699s pour la seule locale fr, previsiblement ~1360s pour
fr+en combines) - confiance nettement retablie sur les techniques
auparavant en echec au spot-check manuel (sweat ~0.99).

Consequence directe : le temps de demarrage du service passe d'environ
11 a environ 23 minutes. start_period (docker-compose.yml) et le timeout
d'attente /health (ci.yml) releves de 900s a 1800s en consequence.

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

* fix(recipes): reequilibre le corpus via substitution de synonyme plutot que du remplissage generique

Deux tentatives precedentes de porter chaque technique a 20 utterances
ont mesurablement degrade le F1 agrege (tech-step-eval.test.ts, 0.80 ->
0.79/0.791) au lieu de l'ameliorer : le generateur reposait surtout sur
des tournures modales generiques ("il faut ...", "make sure to ..."),
partagees identiquement par les 74 classes - un textcat bag-of-words lit
ca comme une separabilite reduite entre classes, pas un padding neutre.

augment_utterances.py revu : priorite a la substitution de synonyme
(l'un des synonyms propres a la technique en tete d'une utterance
existante, remplace par un autre - vocabulaire genuinement distinctif),
les tournures modales ne servant plus qu'de complement limite (5 par
locale, pas 12). Resultat : 13 a 20 utterances par technique/locale
(moyenne ~19.7), contre un forcage uniforme a 20 qui necessitait un
remplissage generique disproportionne pour les techniques au vocabulaire
propre pauvre (julienne, sweat, bainMarie - precisement celles qui
echouaient). Confiance mesuree nettement retablie sur ces techniques
(sweat ~0.99, bainMarie ~0.98, julienne ~0.88).

tests/test_training_data_balance.py : plancher abaisse a 12 (vise 20,
garanti seulement si le vocabulaire propre de la technique le permet
sans repasser par le piege ci-dessus) ; suppression de l'exigence
fr/en egaux, plus vraie avec cette strategie (le potentiel de
substitution differe naturellement entre les deux langues).

Suite complete locale : 35/35 verts (22m26s).

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

* revert(recipes): annule le reequilibrage du corpus d'entrainement du textcat

Trois strategies de generation differentes (tournures modales generiques,
tournures reduites + substitution de synonyme, substitution de synonyme
en priorite) ont ete tentees pour porter chaque technique a 20 utterances
par locale. Les trois degradent mesurablement le F1 agrege contre
TECH_STEP_EVAL_DATASET (tech-step-eval.test.ts) en dessous du seuil 0.8 :
0.7999 -> 0.791 -> 0.744 (chaque tentative pire que la precedente).

tech-step-eval-runner.ts documente explicitement ce seuil comme calibre
avec une marge deja tres etroite (0.8 pour un score mesure a 0.815) et
previent contre le fait de l'assouplir pour accommoder un classifieur
plus faible plutot que de corriger le probleme de fond - assouplir le
seuil ou le jeu d'evaluation pour faire passer cette PR irait a l'encontre
de cette convention documentee du projet.

Revient a l'etat d'avant tout reequilibrage (corpus a 3-7 utterances/
technique, _TRAINING_ITERATIONS=25, timeouts a 900s) - le dernier etat
confirme vert en CI sur cette branche. Ameliorer reellement l'equilibre
du corpus necessite du contenu redige a la main et verifie technique par
technique contre ce meme F1, pas une generation programmatique en bloc.

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

* fix(recipes): reequilibre le corpus via substitution de synonyme plutot que du remplissage generique

Trois tentatives precedentes d'egaliser chaque technique a 20 utterances
ont toutes degrade le F1 agrege sous 0.8 (voir le commit revert
precedent). Nouvelle strategie, beaucoup plus conservatrice : egalise
chaque technique vers le maximum DEJA present dans le corpus (7 en fr,
5 en en, portes par cook/preheat), pas vers un nombre choisi dans
l'absolu - +3-4 utterances en moyenne par technique au lieu de +13-17.

augment_utterances.py (nouveau, reutilisable) genere le complement en
priorite par substitution de synonyme (un des synonyms propres a la
technique, en tete d'une utterance existante, remplace par un autre) -
avec un garde-fou supplementaire par rapport aux tentatives precedentes :
le synonyme de remplacement doit lui aussi etre a l'imperatif/infinitif,
pas juste le synonyme d'origine, pour eviter de substituer un groupe
nominal/adjectif ("a petit feu", "gros bouillons") a la place d'un
verbe et produire une phrase grammaticalement cassee. Tournures modales
uniquement en dernier recours pour les techniques dont le vocabulaire
n'apparait qu'en milieu de phrase (julienne, brunoise...).

Resultat : chaque technique a exactement 7 utterances en fr et 5 en en,
sans exception (tests/test_training_data_balance.py fait respecter cet
invariant). _TRAINING_ITERATIONS reste a 25 (inchange). start_period/
timeout d'attente /health releves de 900s a 1200s (temps d'entrainement
mesure ~930s contre ~670s avant, la marge de securite existante etait
devenue trop juste).

Suite complete locale : 35/35 verts (14m41s).

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

* chore: retrigger CI (aucun run genere pour c7116d4, probable incident GitHub Actions)

---------

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

1419 lines
56 KiB
TypeScript

import type {
AllergenKind,
IngredientCategory,
IngredientIcon,
IngredientSubcategory,
PrismaClient,
UnitType,
} from "@prisma/client";
// Short, optional-to-pick regime list — `UserProfile.dietId` stays
// nullable, this is not meant to be exhaustive. Authored directly as
// English camelCase `uid`s (the exact value stored as `Diet.key`) — no
// French label, no separate lookup table (see the module doc comment on
// `seedReferenceData` below). The French display text for each lives
// solely in `apps/web`'s `locales/fr/translation.json` (`catalog.diets`),
// maintained independently, tied together only by this same uid string.
export const DIETS = ["omnivore", "vegetarian", "vegan", "pescatarian", "glutenFree"];
// Recipe ingredient units — a closed, normalized set replacing what used to
// be free text (see `Unit`/`RecipeIngredient.unitId` in schema.prisma for
// why). `toBaseFactor` is how many of the type's base unit (gram for MASS,
// milliliter for VOLUME, itself for COUNT) one of this unit equals — MASS
// and VOLUME units convert against each other within their own type, COUNT
// units don't convert to one another at all (a "pincée" isn't a fixed
// fraction of a "gousse"), so each just gets `1`. Same "English camelCase
// uid, no French label" authoring as `DIETS`/`ALLERGENS` — the display
// label lives in `apps/web`'s `locales/fr/translation.json` under
// `catalog.units.<key>`.
export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }> = [
{ uid: "gram", type: "MASS", toBaseFactor: 1 },
{ uid: "kilogram", type: "MASS", toBaseFactor: 1000 },
{ uid: "milliliter", type: "VOLUME", toBaseFactor: 1 },
{ uid: "centiliter", type: "VOLUME", toBaseFactor: 10 },
{ uid: "liter", type: "VOLUME", toBaseFactor: 1000 },
{ uid: "tablespoon", type: "VOLUME", toBaseFactor: 15 },
{ uid: "teaspoon", type: "VOLUME", toBaseFactor: 5 },
{ uid: "piece", type: "COUNT", toBaseFactor: 1 },
{ uid: "pinch", type: "COUNT", toBaseFactor: 1 },
{ uid: "slice", type: "COUNT", toBaseFactor: 1 },
{ uid: "clove", type: "COUNT", toBaseFactor: 1 },
{ uid: "bunch", type: "COUNT", toBaseFactor: 1 },
{ uid: "sachet", type: "COUNT", toBaseFactor: 1 },
{ uid: "sprig", type: "COUNT", toBaseFactor: 1 },
// US customary units — added for English-language sources (TheMealDB and
// similar routinely state quantities in cups/oz/lb, not metric), so
// `ingredient-matcher.ts` has something to resolve them to instead of
// always falling back to `unitId: null`. Convertible against their
// metric siblings like any other MASS/VOLUME unit.
{ uid: "cup", type: "VOLUME", toBaseFactor: 236.5882 },
{ uid: "ounce", type: "MASS", toBaseFactor: 28.3495 },
{ uid: "pound", type: "MASS", toBaseFactor: 453.5924 },
];
// Cooking-technique catalog (French recipe-step normalization) — the
// stable `key`s `tech-step-matcher.ts` auto-detects in a free-text
// `Step.description` (a step can mention several, e.g. "faire chauffer une
// poêle puis y faire fondre le beurre" is both `preheat` and `melt` — see
// `Step.techSteps`/`StepTechStep` in schema.prisma). Same "English
// camelCase uid, no French label" authoring as DIETS/UNITS — the label
// lives in apps/web's locales/fr/translation.json under
// `catalog.techSteps.<key>`.
//
// Just a flat list of stable ids here — the actual matching data (per-
// locale synonym lists + example phrasings the classifier trains on) lives
// in `services/tech-step-intent-service/intent_service/training_data.py`'s
// `TECH_STEP_TRAINING_DATA`, not here: it's owned and trained entirely by
// that separate Python service (see its own README), not read by this
// seed script at all, so it doesn't belong alongside the rest of this
// file's DB-seeded reference data. Every entry here must have a matching
// entry there.
export const TECH_STEPS: string[] = [
"cook",
"fry",
"melt",
"deglaze",
"simmer",
"boil",
"roast",
"grill",
"panFry",
"blanch",
"marinate",
"chop",
"peel",
"mince",
"mix",
"whisk",
"foldIn",
"setAside",
"season",
"drain",
"brown",
"rest",
"preheat",
"bake",
"plate",
"coat",
// Lexique de techniques ajouté par la suite — voir
// `services/tech-step-intent-service/intent_service/training_data.py`
// pour les synonymes/phrases d'exemple de chacune.
"baste",
"appertize",
"whiskPale",
"goldenBrown",
"braise",
"truss",
"caramelize",
"score",
"lineMold",
"clarify",
"compote",
"concasse",
"confit",
"julienne",
"brunoise",
"mirepoix",
"paysanne",
"blindBake",
"bainMarie",
"smother",
"decant",
"dilute",
"punchDown",
"disgorge",
"loosen",
"shellEgg",
"scald",
"pod",
"emulsify",
"hollowOut",
"shock",
"setGel",
"glaze",
"thicken",
"filet",
"proof",
"peelBlanch",
"whipUp",
"moisten",
"pasteurize",
"poach",
"reduce",
"rubIn",
"dustWithFlour",
"sweat",
"sift",
"toast",
"zest",
];
// Same authoring convention as `TECH_STEPS` right above (stable English
// camelCase uid, French label in `apps/web`'s `locales/fr/translation.json`
// under `catalog.utensils.<key>`) — but unlike `TECH_STEPS`, the matching
// data (per-locale synonym lists a `PhraseMatcher` matches against) lives
// in `services/tech-step-intent-service/intent_service/utensil_vocabulary.py`'s
// `UTENSIL_VOCABULARY`, not `training_data.py`: no textcat/training
// involved, a utensil mention doesn't need to be classified, only matched.
// Every entry here must have a matching entry there. See
// `StepTechStepUtensil` in schema.prisma for how a mention gets attached to
// a detected technique.
export const UTENSILS: string[] = [
"pan",
"saucepan",
"pot",
"knife",
"whisk",
"bowl",
"bakingSheet",
"mold",
"colander",
"cuttingBoard",
"oven",
"blender",
"mixer",
"spatula",
"ladle",
"grater",
"rollingPin",
"lid",
"tongs",
"peeler",
"sieve",
"foodProcessor",
"steamerBasket",
"skewer",
"pastryBrush",
"ramekin",
"dish",
"wok",
"thermometer",
"mandoline",
];
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
// businesses to declare — a standard, defensible reference list rather than
// an invented one. Split into ALLERGY (classic IgE-mediated immune
// reaction) vs INTOLERANCE (non-immune — gluten sensitivity, sulfite
// sensitivity) per the product decision discussed in chat: only Gluten and
// Sulfites are commonly-recognized intolerances among the 14; the rest are
// true allergens.
export const ALLERGENS: Array<{ uid: string; kind: AllergenKind }> = [
{ uid: "gluten", kind: "INTOLERANCE" },
{ uid: "crustaceans", kind: "ALLERGY" },
{ uid: "eggs", kind: "ALLERGY" },
{ uid: "fish", kind: "ALLERGY" },
{ uid: "peanuts", kind: "ALLERGY" },
{ uid: "soy", kind: "ALLERGY" },
{ uid: "milk", kind: "ALLERGY" },
{ uid: "treeNuts", kind: "ALLERGY" },
{ uid: "celery", kind: "ALLERGY" },
{ uid: "mustard", kind: "ALLERGY" },
{ uid: "sesameSeeds", kind: "ALLERGY" },
{ uid: "sulfites", kind: "INTOLERANCE" },
{ uid: "lupin", kind: "ALLERGY" },
{ uid: "molluscs", kind: "ALLERGY" },
];
interface IngredientSeed {
/** English camelCase identifier, authored directly — also the DB `key` value (no French label, no separate lookup table). */
uid: string;
/**
* Generic pictogram type, overriding its group's `defaultIcon` below —
* only needed for the exceptions within a subcategory (a wedge of cheese
* inside an otherwise-milk "produits laitiers" group, a stockpot inside
* an otherwise-flour "bases" group…). See `IngredientIcon` in
* schema.prisma and `apps/web`'s `features/recipes/ingredient-icons.tsx`
* for the actual pictograms — this was a free-text emoji field until the
* product decision recorded in chat replaced it with this small, shared
* vocabulary.
*/
icon?: IngredientIcon;
allergenUids: string[];
/**
* Diet regimes this ingredient is compatible with, overriding its group's
* `defaultDiets` below — only needed for the exceptions within a
* subcategory (a fish-based stock inside an otherwise-vegan "bases"
* group, a butter-based dough inside an otherwise-vegan "pâtes à
* cuire"…). References `DIETS` by uid, same as `allergenUids`
* references `ALLERGENS`. Deliberately never includes `"omnivore"`
* (trivial, every ingredient qualifies) or `"glutenFree"` (derived from
* `allergenUids` instead — see `IngredientDiet` in schema.prisma for
* why).
*/
dietUids?: string[];
/**
* 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) — see `Ingredient.reproducible` in schema.prisma.
* Omitted (falsy) by default; only set `true` on the curated subset this
* is actually true for. Never group-level (unlike `defaultDiets`/
* `defaultIcon`) — even a homogeneous-looking group like "Pains" mixes
* genuinely home-bakeable items (`Pain`, `Naan`) with ones nobody
* realistically bakes from scratch (`Pain de seigle`, `Biscotte`), so this
* needs a per-item judgment call, not a group default.
*/
reproducible?: boolean;
}
// A broad pantry list — the goal is to cover the large majority of what a
// home cook reaches for (viandes, poissons, légumes, fruits, féculents,
// condiments, épices...), not just enough to exercise the recipe catalog in
// tests. Ingredients are reference data (see `Ingredient` in schema.prisma:
// `key` is `@unique`, there's no create/update/delete endpoint), so this is
// meant to already be comprehensive at first deploy rather than grown
// piecemeal as recipes need more of it. `allergenUids` reference
// `ALLERGENS` above by uid — every one of the 14 EU-regulated allergens is
// covered by at least one ingredient here.
//
// Grouped by (`category`, `subcategory`) — mirrors `IngredientCategory`/
// `IngredientSubcategory` in schema.prisma, a supermarket-aisle taxonomy
// ("rayons") reworked from an earlier, less intuitive scheme that mixed
// cuisine-of-origin buckets ("cuisine italienne") in with aisle-style ones
// ("légumes") — an ingredient's category used to depend on which angle you
// thought of first. The picker UI (`apps/web`'s `IngredientPicker`) uses
// this to offer category browsing, not just free-text search — with 400+
// ingredients, search alone doesn't scale to actually *finding* something,
// and a single flat list of 7 aisles alone wouldn't either (some aisles
// would be 100+ items deep). Each group's key is the single source of
// truth for that mapping; `INGREDIENTS` below just flattens it back to one
// array for the seeding loop.
//
// `defaultDiets`/`defaultIcon` are what every item in the group shares
// unless it sets its own `dietUids`/`icon` — most groups are homogeneous
// on both counts (a vegetable is always vegan and always looks like a
// vegetable; a cut of meat never is and never does), so this avoids
// repeating the same values on hundreds of items; only a group's
// exceptions (a fish-based stock inside "bases", a cheese inside "produits
// laitiers"…) need a per-item override.
export const INGREDIENT_GROUPS: Array<{
category: IngredientCategory;
subcategory: IngredientSubcategory;
defaultDiets: string[];
defaultIcon: IngredientIcon;
items: IngredientSeed[];
}> = [
// =========================================================================
// Produits frais
// =========================================================================
{
category: "freshProduce",
subcategory: "vegetables",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "VEGETABLE",
items: [
{ uid: "tomato", allergenUids: [] },
{ uid: "onion", allergenUids: [] },
{ uid: "shallot", allergenUids: [] },
{ uid: "garlic", allergenUids: [] },
{ uid: "carrot", allergenUids: [] },
{ uid: "zucchini", allergenUids: [] },
{ uid: "cucumber", allergenUids: [] },
{ uid: "gherkins", allergenUids: [] },
{ uid: "bellPepper", allergenUids: [] },
{ uid: "mushroom", allergenUids: [] },
{ uid: "porcini", allergenUids: [] },
{ uid: "eggplant", allergenUids: [] },
{ uid: "broccoli", allergenUids: [] },
{ uid: "cauliflower", allergenUids: [] },
{ uid: "whiteCabbage", allergenUids: [] },
{ uid: "redCabbage", allergenUids: [] },
{ uid: "brusselsSprouts", allergenUids: [] },
{ uid: "spinach", allergenUids: [] },
{ uid: "swissChard", allergenUids: [] },
{ uid: "lettuce", allergenUids: [] },
{ uid: "arugula", allergenUids: [] },
{ uid: "watercress", allergenUids: [] },
{ uid: "leek", allergenUids: [] },
{ uid: "celery", allergenUids: ["celery"] },
{ uid: "radish", allergenUids: [] },
{ uid: "beetroot", allergenUids: [] },
{ uid: "turnip", allergenUids: [] },
{ uid: "parsnip", allergenUids: [] },
{ uid: "greenBean", allergenUids: [] },
{ uid: "pea", allergenUids: [] },
{ uid: "corn", allergenUids: [] },
{ uid: "artichoke", allergenUids: [] },
{ uid: "fennel", allergenUids: [] },
{ uid: "endive", allergenUids: [] },
{ uid: "pumpkin", allergenUids: [] },
{ uid: "butternutSquash", allergenUids: [] },
{ uid: "asparagus", allergenUids: [] },
{ uid: "avocado", allergenUids: [] },
{ uid: "potato", allergenUids: [] },
{ uid: "sweetPotato", allergenUids: [] },
{ uid: "cherryTomato", allergenUids: [] },
{ uid: "bokChoy", allergenUids: [] },
{ uid: "soybeanSprouts", allergenUids: ["soy"] },
{ uid: "shiitake", allergenUids: [] },
{ uid: "daikon", allergenUids: [] },
{ uid: "freshGreenChili", allergenUids: [] },
{ uid: "cardoon", allergenUids: [] },
// Second Ciqual pass — see the PR description.
{ uid: "radicchio", allergenUids: [] },
{ uid: "romanesco", allergenUids: [] },
{ uid: "kohlrabi", allergenUids: [] },
{ uid: "napaCabbage", allergenUids: [] },
{ uid: "celeriac", allergenUids: ["celery"] },
{ uid: "okra", allergenUids: [] },
{ uid: "springOnion", allergenUids: [] },
{ uid: "redKuriSquash", allergenUids: [] },
{ uid: "rutabaga", allergenUids: [] },
{ uid: "samphire", allergenUids: [] },
{ uid: "salsify", allergenUids: [] },
{ uid: "lambsLettuce", allergenUids: [] },
{ uid: "escarole", allergenUids: [] },
],
},
{
category: "freshProduce",
subcategory: "fruits",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "FRUIT",
items: [
{ uid: "lemon", allergenUids: [] },
{ uid: "lime", allergenUids: [] },
{ uid: "apple", allergenUids: [] },
{ uid: "pear", allergenUids: [] },
{ uid: "banana", allergenUids: [] },
{ uid: "orange", allergenUids: [] },
{ uid: "clementine", allergenUids: [] },
{ uid: "grapefruit", allergenUids: [] },
{ uid: "strawberry", allergenUids: [] },
{ uid: "raspberry", allergenUids: [] },
{ uid: "blueberry", allergenUids: [] },
{ uid: "blackberry", allergenUids: [] },
{ uid: "cherry", allergenUids: [] },
{ uid: "apricot", allergenUids: [] },
{ uid: "peach", allergenUids: [] },
{ uid: "plum", allergenUids: [] },
{ uid: "grape", allergenUids: [] },
{ uid: "melon", allergenUids: [] },
{ uid: "watermelon", allergenUids: [] },
{ uid: "pineapple", allergenUids: [] },
{ uid: "mango", allergenUids: [] },
{ uid: "kiwi", allergenUids: [] },
{ uid: "fig", allergenUids: [] },
{ uid: "date", allergenUids: [] },
{ uid: "lychee", allergenUids: [] },
{ uid: "pomegranate", allergenUids: [] },
{ uid: "rhubarb", allergenUids: [] },
{ uid: "quince", allergenUids: [] },
{ uid: "blackcurrant", allergenUids: [] },
{ uid: "cranberry", allergenUids: [] },
// Second Ciqual pass.
{ uid: "redcurrant", allergenUids: [] },
{ uid: "persimmon", allergenUids: [] },
{ uid: "nectarine", allergenUids: [] },
{ uid: "tamarind", allergenUids: [] },
],
},
{
category: "freshProduce",
subcategory: "freshHerbs",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "HERB",
items: [
{ uid: "basil", allergenUids: [] },
{ uid: "parsley", allergenUids: [] },
{ uid: "thyme", allergenUids: [] },
{ uid: "rosemary", allergenUids: [] },
{ uid: "bayLeaf", allergenUids: [] },
{ uid: "chives", allergenUids: [] },
{ uid: "freshCilantro", allergenUids: [] },
{ uid: "mint", allergenUids: [] },
{ uid: "oregano", allergenUids: [] },
{ uid: "dill", allergenUids: [] },
{ uid: "tarragon", allergenUids: [] },
{ uid: "savory", allergenUids: [] },
{ uid: "marjoram", allergenUids: [] },
{ uid: "sage", allergenUids: [] },
{ uid: "chervil", allergenUids: [] },
{ uid: "ginger", allergenUids: [] },
{ uid: "lemongrass", allergenUids: [] },
{ uid: "kaffirLime", allergenUids: [] },
],
},
// =========================================================================
// Boucherie & poissonnerie
// =========================================================================
{
category: "meatAndSeafood",
subcategory: "meats",
defaultDiets: [],
defaultIcon: "MEAT",
items: [
{ uid: "rabbit", allergenUids: [] },
{ uid: "groundBeef", allergenUids: [] },
{ uid: "beefSteak", allergenUids: [] },
{ uid: "beefRoast", allergenUids: [] },
{ uid: "vealCutlet", allergenUids: [] },
{ uid: "porkTenderloin", allergenUids: [] },
{ uid: "porkChop", allergenUids: [] },
// Ground/minced meats, requested alongside `groundBeef` (already
// seeded) — one per red-meat type the catalog otherwise only offers
// as a whole cut. Ground poultry (turkey/chicken) lives in the
// `poultry` subcategory below, next to their whole-cut counterparts.
{ uid: "groundVeal", allergenUids: [] },
{ uid: "groundPork", allergenUids: [] },
{ uid: "groundLamb", allergenUids: [] },
{ uid: "lamb", allergenUids: [] },
{ uid: "legOfLamb", allergenUids: [] },
{ uid: "baconLardons", allergenUids: [] },
{ uid: "bacon", allergenUids: [] },
{ uid: "ham", allergenUids: [] },
{ uid: "curedHam", allergenUids: [] },
{ uid: "sausage", allergenUids: [] },
{ uid: "chorizo", allergenUids: [] },
{ uid: "merguez", allergenUids: [] },
{ uid: "prosciutto", allergenUids: [] },
{ uid: "pancetta", allergenUids: [] },
{ uid: "mortadella", allergenUids: [] },
{ uid: "salami", allergenUids: [] },
// Added from the Ciqual 2025 "aliments moyens" table — French
// charcuterie/offal/game staples the pantry list was missing (see
// the PR description for how this batch was sourced/curated).
{ uid: "andouille", allergenUids: [] },
{ uid: "andouillette", allergenUids: [] },
{ uid: "whitePudding", allergenUids: [] },
{ uid: "blackPudding", allergenUids: [] },
{ uid: "cervelat", allergenUids: [] },
{ uid: "rillettes", allergenUids: [] },
{ uid: "dryCuredSausage", allergenUids: [] },
{ uid: "bayonneHam", allergenUids: [] },
{ uid: "coppa", allergenUids: [] },
{ uid: "rosetteSausage", allergenUids: [] },
{ uid: "vealLiver", allergenUids: [] },
{ uid: "vealKidneys", allergenUids: [] },
{ uid: "vealBrain", allergenUids: [] },
{ uid: "vealSweetbread", allergenUids: [] },
{ uid: "beefTongue", allergenUids: [] },
{ uid: "tripe", allergenUids: [] },
{ uid: "venison", allergenUids: [] },
{ uid: "roeDeer", allergenUids: [] },
{ uid: "wildBoar", allergenUids: [] },
// Second Ciqual pass.
{ uid: "horseMeat", allergenUids: [] },
{ uid: "beefHeart", allergenUids: [] },
{ uid: "foieGras", allergenUids: [] },
{ uid: "beefMuzzle", allergenUids: [] },
{ uid: "grisonsDriedBeef", allergenUids: [] },
],
},
{
category: "meatAndSeafood",
subcategory: "poultry",
defaultDiets: [],
defaultIcon: "POULTRY",
items: [
{ uid: "chicken", allergenUids: [] },
{ uid: "groundChicken", allergenUids: [] },
{ uid: "turkey", allergenUids: [] },
{ uid: "groundTurkey", allergenUids: [] },
{ uid: "duck", allergenUids: [] },
{ uid: "duckBreast", allergenUids: [] },
// Ciqual 2025 additions — see the VIANDES group above.
{ uid: "quail", allergenUids: [] },
{ uid: "guineaFowl", allergenUids: [] },
{ uid: "goose", allergenUids: [] },
{ uid: "poultryLiver", allergenUids: [] },
// Second Ciqual pass.
{ uid: "capon", allergenUids: [] },
{ uid: "pigeon", allergenUids: [] },
{ uid: "pheasant", allergenUids: [] },
],
},
{
category: "meatAndSeafood",
subcategory: "fish",
defaultDiets: ["pescatarian"],
defaultIcon: "FISH",
items: [
{ uid: "salmon", allergenUids: ["fish"] },
{ uid: "tuna", allergenUids: ["fish"] },
{ uid: "cod", allergenUids: ["fish"] },
{ uid: "trout", allergenUids: ["fish"] },
{ uid: "sardine", allergenUids: ["fish"] },
{ uid: "anchovy", allergenUids: ["fish"] },
{ uid: "whiting", allergenUids: ["fish"] },
{ uid: "surimi", allergenUids: ["fish"] },
{ uid: "seaBass", allergenUids: ["fish"] },
{ uid: "seaBream", allergenUids: ["fish"] },
{ uid: "sole", allergenUids: ["fish"] },
{ uid: "turbot", allergenUids: ["fish"] },
{ uid: "hake", allergenUids: ["fish"] },
{ uid: "pollock", allergenUids: ["fish"] },
{ uid: "saithe", allergenUids: ["fish"] },
{ uid: "haddock", allergenUids: ["fish"] },
{ uid: "mackerel", allergenUids: ["fish"] },
{ uid: "herring", allergenUids: ["fish"] },
{ uid: "redMullet", allergenUids: ["fish"] },
{ uid: "skate", allergenUids: ["fish"] },
{ uid: "monkfish", allergenUids: ["fish"] },
{ uid: "halibut", allergenUids: ["fish"] },
{ uid: "swordfish", allergenUids: ["fish"] },
{ uid: "carp", allergenUids: ["fish"] },
{ uid: "pike", allergenUids: ["fish"] },
{ uid: "perch", allergenUids: ["fish"] },
{ uid: "tilapia", allergenUids: ["fish"] },
{ uid: "pangasius", allergenUids: ["fish"] },
{ uid: "smokedSalmon", allergenUids: ["fish"] },
{ uid: "driedFish", allergenUids: ["fish"] },
// Ciqual 2025 additions.
{ uid: "eel", allergenUids: ["fish"] },
{ uid: "plaice", allergenUids: ["fish"] },
// Second Ciqual pass.
{ uid: "saltCod", allergenUids: ["fish"] },
{ uid: "lemonSole", allergenUids: ["fish"] },
{ uid: "scorpionfish", allergenUids: ["fish"] },
],
},
{
category: "meatAndSeafood",
subcategory: "shellfish",
defaultDiets: ["pescatarian"],
defaultIcon: "SHELLFISH",
items: [
{ uid: "shrimp", allergenUids: ["crustaceans"] },
{ uid: "langoustine", allergenUids: ["crustaceans"] },
{ uid: "lobster", allergenUids: ["crustaceans"] },
{ uid: "crab", allergenUids: ["crustaceans"] },
{ uid: "spinyLobster", allergenUids: ["crustaceans"] },
{ uid: "mussels", allergenUids: ["molluscs"] },
{ uid: "oysters", allergenUids: ["molluscs"] },
{ uid: "scallops", allergenUids: ["molluscs"] },
{ uid: "squid", allergenUids: ["molluscs"] },
{ uid: "octopus", allergenUids: ["molluscs"] },
{ uid: "clams", allergenUids: ["molluscs"] },
{ uid: "whelks", allergenUids: ["molluscs"] },
// Ciqual 2025 additions.
{ uid: "spiderCrab", allergenUids: ["crustaceans"] },
{ uid: "periwinkle", allergenUids: ["molluscs"] },
// Second Ciqual pass.
{ uid: "crayfish", allergenUids: ["crustaceans"] },
{ uid: "greyShrimp", allergenUids: ["crustaceans"] },
{ uid: "cockle", allergenUids: ["molluscs"] },
{ uid: "snail", allergenUids: ["molluscs"] },
{ uid: "cuttlefish", allergenUids: ["molluscs"] },
],
},
// =========================================================================
// Épicerie sèche
// =========================================================================
{
category: "dryGoods",
subcategory: "starches",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "GRAIN",
items: [
{ uid: "semolina", allergenUids: ["gluten"] },
{ uid: "couscous", allergenUids: ["gluten"] },
{ uid: "bulgur", allergenUids: ["gluten"] },
{ uid: "polenta", allergenUids: [] },
{ uid: "quinoa", allergenUids: [] },
{ uid: "pasta", allergenUids: ["gluten"] },
{ uid: "wholeWheatPasta", allergenUids: ["gluten"] },
{ uid: "rice", allergenUids: [] },
{ uid: "basmatiRice", allergenUids: [] },
{ uid: "brownRice", allergenUids: [] },
{ uid: "oats", allergenUids: ["gluten"] },
{ uid: "spaghetti", allergenUids: ["gluten"] },
{ uid: "penne", allergenUids: ["gluten"] },
{ uid: "tagliatelle", allergenUids: ["gluten"] },
{ uid: "lasagnaSheets", allergenUids: ["gluten"] },
{ uid: "gnocchi", allergenUids: ["gluten"] },
{ uid: "arborioRice", allergenUids: [] },
{ uid: "riceNoodles", allergenUids: [] },
{ uid: "udonNoodles", allergenUids: ["gluten"] },
{ uid: "sobaNoodles", allergenUids: ["gluten"] },
{ uid: "chineseNoodles", allergenUids: ["gluten"] },
{ uid: "riceVermicelli", allergenUids: [] },
{ uid: "soyVermicelli", allergenUids: [] },
{ uid: "stickyRice", allergenUids: [] },
{ uid: "sushiRice", allergenUids: [] },
{ uid: "jasmineRice", allergenUids: [] },
],
},
{
category: "dryGoods",
subcategory: "legumes",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "LEGUME",
items: [
{ uid: "greenLentils", allergenUids: [] },
{ uid: "redLentils", allergenUids: [] },
{ uid: "chickpeas", allergenUids: [] },
{ uid: "whiteBeans", allergenUids: [] },
{ uid: "kidneyBeans", allergenUids: [] },
{ uid: "blackBeans", allergenUids: [] },
{ uid: "splitPeas", allergenUids: [] },
{ uid: "favaBeans", allergenUids: [] },
{ uid: "edamame", allergenUids: ["soy"] },
{ uid: "pintoBeans", allergenUids: [] },
// Second Ciqual pass.
{ uid: "flageoletBeans", allergenUids: [] },
{ uid: "goldenLentils", allergenUids: [] },
],
},
{
category: "dryGoods",
subcategory: "nutsAndSeeds",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "NUT_SEED",
items: [
{ uid: "peanutsShelled", allergenUids: ["peanuts"] },
{ uid: "almonds", allergenUids: ["treeNuts"] },
{ uid: "walnuts", allergenUids: ["treeNuts"] },
{ uid: "hazelnuts", allergenUids: ["treeNuts"] },
{ uid: "cashews", allergenUids: ["treeNuts"] },
{ uid: "pistachios", allergenUids: ["treeNuts"] },
{ uid: "pecans", allergenUids: ["treeNuts"] },
{ uid: "almondPowder", allergenUids: ["treeNuts"] },
{ uid: "pineNuts", allergenUids: [] },
{ uid: "sunflowerSeeds", allergenUids: [] },
{ uid: "pumpkinSeeds", allergenUids: [] },
{ uid: "shreddedCoconut", allergenUids: [] },
{ uid: "raisins", allergenUids: ["sulfites"] },
{ uid: "prunes", allergenUids: ["sulfites"] },
{ uid: "driedApricots", allergenUids: ["sulfites"] },
{ uid: "sesameSeeds", allergenUids: ["sesameSeeds"] },
],
},
{
category: "dryGoods",
subcategory: "other",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "JAR",
items: [
{ uid: "blackMushrooms", allergenUids: [] },
{ uid: "noriSeaweed", allergenUids: [] },
{ uid: "wakameSeaweed", allergenUids: [] },
{ uid: "kombuSeaweed", allergenUids: [] },
{ uid: "bambooShoots", allergenUids: [] },
{ uid: "waterChestnuts", allergenUids: [] },
],
},
// =========================================================================
// Boulangerie
// =========================================================================
{
category: "bakery",
subcategory: "breads",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "BREAD",
items: [
{ uid: "bread", reproducible: true, allergenUids: ["gluten"] },
{ uid: "sandwichBread", reproducible: true, allergenUids: ["gluten"] },
{ uid: "wholeWheatBread", allergenUids: ["gluten"] },
{ uid: "baguette", allergenUids: ["gluten"] },
{ uid: "ryeBread", allergenUids: ["gluten"] },
{ uid: "breadcrumbs", reproducible: true, allergenUids: ["gluten"] },
{
uid: "burgerBun",
reproducible: true,
allergenUids: ["gluten", "milk", "eggs"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "briocheBun",
allergenUids: ["gluten", "milk", "eggs"],
dietUids: ["vegetarian", "pescatarian"],
},
{ uid: "hotDogBun", reproducible: true, allergenUids: ["gluten"] },
{ uid: "pitaBread", reproducible: true, allergenUids: ["gluten"] },
{ uid: "bagel", reproducible: true, allergenUids: ["gluten"] },
{ uid: "naan", reproducible: true, allergenUids: ["gluten"] },
{ uid: "wrapBread", allergenUids: ["gluten"] },
{
uid: "vienneseBread",
allergenUids: ["gluten", "milk"],
dietUids: ["vegetarian", "pescatarian"],
},
{ uid: "countryBread", allergenUids: ["gluten"] },
{ uid: "multigrainBread", allergenUids: ["gluten"] },
{ uid: "breadRoll", allergenUids: ["gluten"] },
{ uid: "swedishBread", allergenUids: ["gluten"] },
{ uid: "glutenFreeBread", allergenUids: [] },
{ uid: "rusk", allergenUids: ["gluten"] },
{ uid: "croutons", reproducible: true, allergenUids: ["gluten"] },
{ uid: "focaccia", reproducible: true, allergenUids: ["gluten"] },
{ uid: "ciabatta", reproducible: true, allergenUids: ["gluten"] },
{ uid: "cornTortilla", allergenUids: [] },
{ uid: "wheatTortilla", allergenUids: ["gluten"] },
// Second Ciqual pass.
{ uid: "breadstick", reproducible: true, allergenUids: ["gluten"] },
],
},
{
category: "bakery",
subcategory: "rawDough",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "DOUGH",
items: [
{
uid: "puffPastry",
reproducible: true,
allergenUids: ["gluten", "milk"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "shortcrustPastry",
reproducible: true,
allergenUids: ["gluten", "milk"],
dietUids: ["vegetarian", "pescatarian"],
},
{ uid: "pizzaDough", reproducible: true, allergenUids: ["gluten"] },
{
uid: "sweetShortcrustPastry",
reproducible: true,
allergenUids: ["gluten", "milk"],
dietUids: ["vegetarian", "pescatarian"],
},
],
},
// =========================================================================
// Crémerie & fromage
// =========================================================================
{
category: "dairyAndCheese",
subcategory: "dairy",
defaultDiets: ["vegetarian", "pescatarian"],
defaultIcon: "MILK",
items: [
{ uid: "milk", allergenUids: ["milk"] },
{ uid: "butter", allergenUids: ["milk"] },
{ uid: "cremeFraiche", allergenUids: ["milk"] },
{ uid: "liquidCream", allergenUids: ["milk"] },
{ uid: "cheese", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "emmental", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "gruyere", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "parmesan", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "mozzarella", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "goatCheese", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "feta", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "comte", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "fromageBlanc", allergenUids: ["milk"] },
{ uid: "mascarpone", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "yogurt", allergenUids: ["milk"] },
{ uid: "burrata", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "ricotta", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "pecorino", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "gorgonzola", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "cheddar", icon: "CHEESE", allergenUids: ["milk"] },
// Ciqual 2025 additions — classic French regional cheeses the
// catalog only had a handful of internationally-known ones for
// (Emmental, Parmesan, Mozzarella…), missing the actual French
// aisle staples Ciqual's "aliments moyens" table surfaced.
{ uid: "brie", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "camembert", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "roquefort", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "munster", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "reblochon", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "cantal", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "beaufort", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "saintNectaire", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "blueCheese", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "cancoillotte", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "tomme", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "epoisses", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "chaource", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "livarot", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "pontLeveque", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "morbier", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "racletteCheese", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "fourmeDAmbert", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "salers", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "ossauIraty", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "vacherin", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "saintMarcellin", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "neufchatel", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "crottinDeChavignol", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "abondanceCheese", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "carreDeLEst", icon: "CHEESE", allergenUids: ["milk"] },
// Second Ciqual pass.
{ uid: "edam", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "gouda", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "mimolette", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "maroilles", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "montDor", icon: "CHEESE", allergenUids: ["milk"] },
{ uid: "kefir", allergenUids: ["milk"] },
{ uid: "greekYogurt", allergenUids: ["milk"] },
],
},
{
category: "dairyAndCheese",
subcategory: "eggs",
defaultDiets: ["vegetarian", "pescatarian"],
defaultIcon: "EGG",
items: [
{ uid: "egg", allergenUids: ["eggs"] },
{ uid: "eggYolk", allergenUids: ["eggs"] },
{ uid: "eggWhite", allergenUids: ["eggs"] },
],
},
{
category: "dairyAndCheese",
subcategory: "plantBasedAlternatives",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "SPROUT",
items: [
{ uid: "coconutMilk", icon: "MILK", allergenUids: [] },
{ uid: "coconutCream", icon: "MILK", allergenUids: [] },
{ uid: "almondMilk", icon: "MILK", allergenUids: ["treeNuts"] },
{ uid: "oatMilk", icon: "MILK", allergenUids: ["gluten"] },
{ uid: "tofu", allergenUids: ["soy"] },
{ uid: "silkenTofu", allergenUids: ["soy"] },
],
},
// =========================================================================
// Condiments & épices
// =========================================================================
{
category: "condimentsAndSpices",
subcategory: "spices",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "SPICE",
items: [
{ uid: "herbesDeProvence", allergenUids: [] },
{ uid: "blackPepper", allergenUids: [] },
{ uid: "paprika", allergenUids: [] },
{ uid: "espelettePepper", allergenUids: [] },
{ uid: "cayennePepper", allergenUids: [] },
{ uid: "cumin", allergenUids: [] },
{ uid: "curryPowder", allergenUids: [] },
{ uid: "turmeric", allergenUids: [] },
{ uid: "cinnamon", allergenUids: [] },
{ uid: "nutmeg", allergenUids: [] },
{ uid: "saffron", allergenUids: [] },
{ uid: "clove", allergenUids: [] },
{ uid: "vanillaBean", allergenUids: [] },
{ uid: "whitePepper", allergenUids: [] },
{ uid: "pinkPepper", allergenUids: [] },
{ uid: "sichuanPepper", allergenUids: [] },
{ uid: "smokedPaprika", allergenUids: [] },
{ uid: "birdEyeChili", allergenUids: [] },
{ uid: "juniperBerries", allergenUids: [] },
{ uid: "starAnise", allergenUids: [] },
{ uid: "greenAnise", allergenUids: [] },
{ uid: "fennelSeeds", allergenUids: [] },
{ uid: "sumac", allergenUids: [] },
{ uid: "nigella", allergenUids: [] },
{ uid: "allspice", allergenUids: [] },
{ uid: "colomboPowder", allergenUids: [] },
{ uid: "baharat", allergenUids: [] },
{ uid: "horseradish", allergenUids: [] },
{ uid: "herbSalt", allergenUids: [] },
{ uid: "celerySalt", allergenUids: ["celery"] },
{ uid: "fleurDeSel", allergenUids: [] },
{ uid: "salt", allergenUids: [] },
{ uid: "fiveSpice", allergenUids: [] },
{ uid: "garamMasala", allergenUids: [] },
{ uid: "corianderSeeds", allergenUids: [] },
{ uid: "groundCoriander", allergenUids: [] },
{ uid: "cardamom", allergenUids: [] },
{ uid: "fenugreek", allergenUids: [] },
{ uid: "jalapeno", allergenUids: [] },
{ uid: "chipotle", allergenUids: [] },
{ uid: "poblanoPepper", allergenUids: [] },
{ uid: "habanero", allergenUids: [] },
{ uid: "rasElHanout", allergenUids: [] },
{ uid: "zaatar", allergenUids: ["sesameSeeds"] },
],
},
{
category: "condimentsAndSpices",
subcategory: "sauces",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "JAR",
items: [
{ uid: "soySauce", allergenUids: ["soy"] },
{ uid: "mustard", allergenUids: ["mustard"] },
{
uid: "mayonnaise",
reproducible: true,
allergenUids: ["eggs"],
dietUids: ["vegetarian", "pescatarian"],
},
{ uid: "ketchup", reproducible: true, allergenUids: [] },
{ uid: "tabasco", allergenUids: [] },
{
uid: "worcestershireSauce",
allergenUids: ["fish"],
dietUids: ["pescatarian"],
},
{
uid: "fishSauce",
allergenUids: ["fish"],
dietUids: ["pescatarian"],
},
{ uid: "wasabi", allergenUids: [] },
{ uid: "harissa", allergenUids: [] },
{ uid: "curryPaste", allergenUids: [] },
{ uid: "peanutButter", allergenUids: ["peanuts"] },
{ uid: "dijonMustard", allergenUids: ["mustard"] },
{ uid: "wholegrainMustard", allergenUids: ["mustard"] },
{ uid: "barbecueSauce", reproducible: true, allergenUids: [] },
{
uid: "tartarSauce",
allergenUids: ["eggs"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "cocktailSauce",
allergenUids: ["eggs"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "bearnaiseSauce",
allergenUids: ["eggs", "milk"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "hollandaiseSauce",
allergenUids: ["eggs", "milk"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "bechamelSauce",
reproducible: true,
allergenUids: ["milk", "gluten"],
dietUids: ["vegetarian", "pescatarian"],
},
{ uid: "teriyakiSauce", allergenUids: ["soy"] },
{
uid: "ponzuSauce",
allergenUids: ["soy", "fish"],
dietUids: ["pescatarian"],
},
{ uid: "chimichurri", allergenUids: [] },
{
uid: "redPesto",
allergenUids: ["milk", "treeNuts"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "pesto",
reproducible: true,
allergenUids: ["milk", "treeNuts"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "oysterSauce",
allergenUids: ["molluscs"],
dietUids: ["pescatarian"],
},
{ uid: "hoisinSauce", allergenUids: ["soy"] },
{ uid: "sriracha", allergenUids: [] },
{ uid: "sweetChiliSauce", allergenUids: [] },
{ uid: "miso", allergenUids: ["soy"] },
{
uid: "shrimpPaste",
allergenUids: ["crustaceans"],
dietUids: ["pescatarian"],
},
{ uid: "redCurryPaste", allergenUids: [] },
{ uid: "greenCurryPaste", allergenUids: [] },
{ uid: "tahini", reproducible: true, allergenUids: ["sesameSeeds"] },
// Second Ciqual pass.
{
uid: "aioli",
reproducible: true,
allergenUids: ["eggs"],
dietUids: ["vegetarian", "pescatarian"],
},
{ uid: "vinaigrette", reproducible: true, allergenUids: [] },
{ uid: "hummus", reproducible: true, allergenUids: ["sesameSeeds"] },
],
},
{
category: "condimentsAndSpices",
subcategory: "seasonings",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "BOTTLE",
items: [
{ uid: "oliveOil", allergenUids: [] },
{ uid: "sunflowerOil", allergenUids: [] },
{ uid: "rapeseedOil", allergenUids: [] },
{ uid: "coconutOil", allergenUids: [] },
{ uid: "sesameOil", allergenUids: ["sesameSeeds"] },
{ uid: "ciderVinegar", allergenUids: [] },
{ uid: "whiteVinegar", allergenUids: [] },
{ uid: "balsamicVinegar", allergenUids: ["sulfites"] },
{ uid: "capers", icon: "JAR", allergenUids: [] },
{ uid: "olives", icon: "JAR", allergenUids: [] },
{ uid: "blackOlives", icon: "JAR", allergenUids: [] },
{ uid: "greenOlives", icon: "JAR", allergenUids: [] },
{ uid: "whiteWine", icon: "DRINK", allergenUids: ["sulfites"] },
{ uid: "redWine", icon: "DRINK", allergenUids: ["sulfites"] },
{ uid: "roseWine", icon: "DRINK", allergenUids: ["sulfites"] },
{ uid: "redWineVinegar", allergenUids: ["sulfites"] },
{ uid: "whiteWineVinegar", allergenUids: ["sulfites"] },
{ uid: "sherryVinegar", allergenUids: ["sulfites"] },
{ uid: "walnutOil", allergenUids: ["treeNuts"] },
{ uid: "hazelnutOil", allergenUids: ["treeNuts"] },
{ uid: "peanutOil", allergenUids: ["peanuts"] },
{ uid: "chiliOil", allergenUids: [] },
{ uid: "riceVinegar", allergenUids: [] },
// Second Ciqual pass.
{ uid: "cornOil", allergenUids: [] },
{ uid: "grapeseedOil", allergenUids: [] },
{ uid: "soybeanOil", allergenUids: ["soy"] },
{ uid: "palmOil", allergenUids: [] },
{ uid: "mirin", icon: "DRINK", allergenUids: [] },
{ uid: "sake", icon: "DRINK", allergenUids: [] },
{ uid: "lemonJuice", icon: "DRINK", allergenUids: [] },
{ uid: "limeJuice", icon: "DRINK", allergenUids: [] },
{ uid: "orangeJuice", icon: "DRINK", allergenUids: [] },
{ uid: "appleJuice", icon: "DRINK", allergenUids: [] },
{ uid: "grapeJuice", icon: "DRINK", allergenUids: [] },
{ uid: "tomatoJuice", icon: "DRINK", allergenUids: [] },
{ uid: "cranberryJuice", icon: "DRINK", allergenUids: [] },
{ uid: "coffee", icon: "DRINK", allergenUids: [] },
{ uid: "tea", icon: "DRINK", allergenUids: [] },
{ uid: "beer", icon: "DRINK", allergenUids: ["gluten"] },
{ uid: "cider", icon: "DRINK", allergenUids: ["sulfites"] },
{
uid: "champagne",
icon: "DRINK",
allergenUids: ["sulfites"],
},
{ uid: "portWine", icon: "DRINK", allergenUids: ["sulfites"] },
{ uid: "vinJaune", icon: "DRINK", allergenUids: ["sulfites"] },
{ uid: "cognac", icon: "DRINK", allergenUids: [] },
{ uid: "rum", icon: "DRINK", allergenUids: [] },
{ uid: "whisky", icon: "DRINK", allergenUids: [] },
{ uid: "vodka", icon: "DRINK", allergenUids: [] },
],
},
// =========================================================================
// Aides culinaires
// =========================================================================
{
category: "cookingEssentials",
subcategory: "bases",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "GRAIN",
items: [
{ uid: "wheatFlour", allergenUids: ["gluten"] },
{ uid: "wholeWheatFlour", allergenUids: ["gluten"] },
{ uid: "cornFlour", allergenUids: [] },
{ uid: "buckwheatFlour", allergenUids: [] },
{ uid: "riceFlour", allergenUids: [] },
{ uid: "vegetableStockCube", icon: "STOCK_POT", allergenUids: ["celery"] },
{
uid: "chickenStockCube",
icon: "STOCK_POT",
allergenUids: ["celery"],
dietUids: [],
},
{ uid: "tomatoPaste", icon: "JAR", allergenUids: [] },
{ uid: "tomatoCoulis", icon: "JAR", allergenUids: [] },
{ uid: "cannedPeeledTomatoes", icon: "JAR", allergenUids: [] },
{ uid: "sunDriedTomatoes", icon: "JAR", allergenUids: [] },
{
uid: "vealStock",
icon: "STOCK_POT",
reproducible: true,
allergenUids: [],
dietUids: [],
},
{
uid: "chickenStock",
icon: "STOCK_POT",
reproducible: true,
allergenUids: [],
dietUids: [],
},
{
uid: "beefStockCube",
icon: "STOCK_POT",
allergenUids: ["celery"],
dietUids: [],
},
{
uid: "fishStockCube",
icon: "STOCK_POT",
allergenUids: ["fish", "celery"],
dietUids: ["pescatarian"],
},
{
uid: "vegetableBroth",
icon: "STOCK_POT",
reproducible: true,
allergenUids: ["celery"],
},
{
uid: "chickenBroth",
icon: "STOCK_POT",
reproducible: true,
allergenUids: ["celery"],
dietUids: [],
},
{
uid: "beefBroth",
icon: "STOCK_POT",
reproducible: true,
allergenUids: ["celery"],
dietUids: [],
},
{ uid: "courtBouillon", icon: "STOCK_POT", allergenUids: [] },
{
uid: "dashi",
icon: "STOCK_POT",
allergenUids: ["fish"],
dietUids: ["pescatarian"],
},
{
uid: "shellfishBisque",
icon: "STOCK_POT",
allergenUids: ["crustaceans"],
dietUids: ["pescatarian"],
},
{ uid: "tapiocaFlour", allergenUids: [] },
{ uid: "masaHarina", allergenUids: [] },
{ uid: "water", icon: "DRINK", allergenUids: [] },
{ uid: "sparklingWater", icon: "DRINK", allergenUids: [] },
{ uid: "orangeBlossomWater", icon: "DRINK", allergenUids: [] },
{ uid: "roseWater", icon: "DRINK", allergenUids: [] },
{
uid: "fishFumet",
icon: "STOCK_POT",
reproducible: true,
allergenUids: ["fish"],
dietUids: ["pescatarian"],
},
],
},
{
category: "cookingEssentials",
subcategory: "thickeners",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "JAR",
items: [
{ uid: "bakersYeast", allergenUids: [] },
{ uid: "bakingPowder", allergenUids: [] },
{ uid: "cornstarch", allergenUids: [] },
{ uid: "lupinFlour", allergenUids: ["lupin"] },
// Animal collagen (bones/skin, usually pork or beef) — not
// vegetarian/vegan, and not reliably fish-derived either, so no
// pescetarian flag.
{ uid: "gelatin", allergenUids: [], dietUids: [] },
{ uid: "bakingSoda", allergenUids: [] },
{ uid: "potatoStarch", allergenUids: [] },
],
},
{
category: "cookingEssentials",
subcategory: "sugars",
defaultDiets: ["vegetarian", "vegan", "pescatarian"],
defaultIcon: "SUGAR",
items: [
{ uid: "sugar", allergenUids: [] },
{ uid: "honey", allergenUids: [], dietUids: ["vegetarian", "pescatarian"] },
{ uid: "mapleSyrup", allergenUids: [] },
{ uid: "brownSugar", allergenUids: [] },
{ uid: "powderedSugar", allergenUids: [] },
{ uid: "demeraraSugar", allergenUids: [] },
{ uid: "darkChocolate", allergenUids: [] },
{
uid: "milkChocolate",
allergenUids: ["milk"],
dietUids: ["vegetarian", "pescatarian"],
},
{
uid: "whiteChocolate",
allergenUids: ["milk"],
dietUids: ["vegetarian", "pescatarian"],
},
{ uid: "chocolateChips", allergenUids: [] },
{ uid: "cocoaPowder", allergenUids: [] },
{ uid: "vanillaExtract", allergenUids: [] },
{ uid: "palmSugar", allergenUids: [] },
{ uid: "caneSyrup", allergenUids: [] },
],
},
];
const INGREDIENTS: Array<
Omit<IngredientSeed, "icon" | "dietUids" | "reproducible"> & {
category: IngredientCategory;
subcategory: IngredientSubcategory;
icon: IngredientIcon;
dietUids: string[];
reproducible: boolean;
}
> = INGREDIENT_GROUPS.flatMap(({ category, subcategory, defaultDiets, defaultIcon, items }) =>
items.map((item) => ({
...item,
category,
subcategory,
icon: item.icon ?? defaultIcon,
dietUids: item.dietUids ?? defaultDiets,
reproducible: item.reproducible ?? false,
})),
);
/**
* Populates the `Diet`/`Category`/`Allergy` reference tables. Idempotent
* (safe to call against a database that already has this data — upserts by
* `key`, all `@unique`) — used both by `prisma/seed.ts` (the CLI entry
* point, `prisma db seed`) and by `test-support/reset-db.ts` (so every
* test starts from the same realistic reference data the real app seeds,
* not an empty table).
*
* Every `uid` below (`DIETS`, `ALLERGENS`, `INGREDIENT_GROUPS`) *is* the
* database `key`, authored directly — no French label, no separate
* lookup/translation table (that used to be `catalog-en-keys.ts` +
* `getEnglishKey()`, removed: product decision recorded in chat, this file
* carries zero natural-language text now). The French display label for
* each uid lives solely in `apps/web`'s `locales/fr/translation.json`
* (`catalog.*` namespace), maintained independently — adding a new
* diet/allergen/ingredient here means also adding its translation there by
* hand, tied together only by the matching uid string.
*/
export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
for (const key of DIETS) {
await prisma.diet.upsert({ where: { key }, update: {}, create: { key } });
}
// `update: { type, toBaseFactor }` (not `{}`) — same reasoning as
// `ALLERGENS`' `kind` below: a reseed must correct a unit's
// type/toBaseFactor if it's ever edited above, not just skip existing rows.
for (const { uid: key, type, toBaseFactor } of UNITS) {
await prisma.unit.upsert({
where: { key },
update: { type, toBaseFactor },
create: { key, type, toBaseFactor },
});
}
// TechStep: upsert by key (same idempotent-seed reasoning as everything
// above) — just the stable id/key rows themselves now, no matching data
// to replace alongside them (see `TECH_STEPS`' own comment for why).
for (const key of TECH_STEPS) {
await prisma.techStep.upsert({ where: { key }, update: {}, create: { key } });
}
// Utensil: same idempotent bare id/key upsert as TechStep right above —
// no matching data alongside it either (see `UTENSILS`' own comment).
for (const key of UTENSILS) {
await prisma.utensil.upsert({ where: { key }, update: {}, create: { key } });
}
// `Allergy` itself carries no `key` — it's the selectable instance of a
// keyed `Category` (see schema.prisma) — so seeding an allergen means one
// Category (upserted by key) plus exactly one Allergy row under it,
// created only the first time. `update: { kind }` (not `{}`) — a reseed
// must correct `kind` on an already-existing category if the
// classification above ever changes, not just skip it.
for (const { uid: key, kind } of ALLERGENS) {
const category = await prisma.category.upsert({
where: { key },
update: { kind },
create: { key, kind },
});
const existing = await prisma.allergy.findFirst({ where: { categoryId: category.id } });
if (!existing) {
await prisma.allergy.create({ data: { categoryId: category.id } });
}
}
// Ingredients: bulk, not one upsert per row (`INGREDIENTS` is a few
// hundred entries long, and `seedReferenceData` re-runs on every single
// test's `resetDatabase()` — a per-row round trip made the whole suite
// measurably slower). Bulk-create whatever's missing in one query, then
// reconcile `icon`/`category`/`subcategory` only for the rows where any
// of them actually changed — on a freshly-truncated table (the common
// test-suite case) that's zero updates, on a real re-deploy it's however
// many rows were edited in code since the last deploy, never the full
// list.
const ingredientKeys = INGREDIENTS.map((i) => i.uid);
const existingIngredients = await prisma.ingredient.findMany({
where: { key: { in: ingredientKeys } },
select: {
id: true,
key: true,
icon: true,
category: true,
subcategory: true,
reproducible: true,
},
});
const existingByKey = new Map(existingIngredients.map((i) => [i.key, i]));
const missingIngredients = INGREDIENTS.filter((i) => !existingByKey.has(i.uid));
if (missingIngredients.length > 0) {
await prisma.ingredient.createMany({
data: missingIngredients.map(({ uid, icon, category, subcategory, reproducible }) => ({
key: uid,
icon,
category,
subcategory,
reproducible,
})),
});
}
const changed = INGREDIENTS.filter((i) => {
const existing = existingByKey.get(i.uid);
return (
existing &&
(existing.icon !== i.icon ||
existing.category !== i.category ||
existing.subcategory !== i.subcategory ||
existing.reproducible !== i.reproducible)
);
});
for (const { uid, icon, category, subcategory, reproducible } of changed) {
await prisma.ingredient.update({
where: { key: uid },
data: { icon, category, subcategory, reproducible },
});
}
// Re-resolve every ingredient's id (existing + just-created) and every
// allergy's id (by its category key) once, then link them in a single
// bulk insert — same "re-derived every time, not upserted per link"
// reasoning as before for `IngredientAllergy` (it has no natural per-row
// identity to upsert against), just batched instead of looped.
const allIngredients = await prisma.ingredient.findMany({
where: { key: { in: ingredientKeys } },
select: { id: true, key: true },
});
const ingredientIdByKey = new Map(allIngredients.map((i) => [i.key, i.id]));
const allergies = await prisma.allergy.findMany({ include: { category: true } });
const allergyIdByCategoryKey = new Map(allergies.map((a) => [a.category.key, a.id]));
const links: Array<{ ingredientId: number; allergyId: number }> = [];
for (const { uid, allergenUids } of INGREDIENTS) {
const ingredientId = ingredientIdByKey.get(uid);
if (ingredientId === undefined) continue;
for (const allergenUid of allergenUids) {
const allergyId = allergyIdByCategoryKey.get(allergenUid);
if (allergyId !== undefined) links.push({ ingredientId, allergyId });
}
}
if (links.length > 0) {
await prisma.ingredientAllergy.createMany({ data: links, skipDuplicates: true });
}
// Same bulk-insert approach as the allergy links above, resolved against
// `dietUids` (item override, falling back to its group's `defaultDiets`
// in the `INGREDIENTS` flatten step) instead of `allergenUids`.
const diets = await prisma.diet.findMany();
const dietIdByKey = new Map(diets.map((d) => [d.key, d.id]));
const dietLinks: Array<{ ingredientId: number; dietId: number }> = [];
for (const { uid, dietUids } of INGREDIENTS) {
const ingredientId = ingredientIdByKey.get(uid);
if (ingredientId === undefined) continue;
for (const dietUid of dietUids) {
const dietId = dietIdByKey.get(dietUid);
if (dietId !== undefined) dietLinks.push({ ingredientId, dietId });
}
}
if (dietLinks.length > 0) {
await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true });
}
}