* feat(recipes): migre la detection des tech steps de node-nlp vers un microservice Python spaCy Remplace TechStepClassifierService's node-nlp (NlpManager) par services/tech-step-intent-service, un microservice FastAPI/spaCy dedie (PhraseMatcher pour le NER par synonymes, textcat pour la classification d'intention). Corpus (TECH_STEP_TRAINING_DATA) toujours possede par apps/api, pousse au service via POST /v1/train a chaque warm-up ; le service ne touche jamais Postgres (meme posture que services/tech-step-llm-worker). Cote apps/api : - intent-service-client.ts : client HTTP vers le nouveau service - tech-step-matcher.ts : delegue NER + intent classification au client, logique pure (splitIntoClauses, seuil/fallback) inchangee - env.ts : INTENT_SERVICE_BASE_URL/INTENT_SERVICE_SECRET (secret requis, service coeur non optionnel) - server.ts : warm-up avec retry/backoff (service Python demarre a part) - scripts/calibrate-tech-step-threshold.ts : recalibration empirique de CONFIDENCE_THRESHOLD contre le jeu d'eval existant - node-nlp retire (package.json, node-nlp.d.ts, model.nlp du .gitignore) docker-compose.yml : nouveau service tech-step-intent-service (pas de port expose, healthcheck, app en depend). CI : job intent-service-test (pytest) + le job test demarre le service en arriere-plan avant la suite Mocha (jamais de mock d'un service interne, cf specs/dev-conventions.md). Verifie : 26/26 tests pytest du service (dont les offsets caracteres exacts de tech-step-matcher.test.ts), lint + build complets du monorepo, smoke test HTTP reel bout en bout. La suite Mocha et docker compose build/up n'ont pas pu etre executes dans cet environnement (pas de Postgres/Docker disponibles ici) — a confirmer via la CI et en local. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): corrige les matches dupliques et le timeout de warm-up des tests CI Deux bugs reels trouves par la premiere execution CI de la migration node-nlp -> tech-step-intent-service : 1. PhraseMatcher retourne tous les matches y compris chevauchants — un synonyme comme "fondre" litteralement contenu dans "faire fondre" (tous deux synonymes de `melt`) produisait deux candidats separes pour la meme technique, dupliquant son techStepId dans le resultat final. Fixe avec spacy.util.filter_spans (garde le plus long match par position) dans LocalePipeline.process. Test de non-regression ajoute. 2. La suite Mocha construit `app` directement via createApp(), sans jamais passer par server.ts — le warm-up (POST /v1/train fr+en sur le corpus complet) se declenchait donc paresseusement dans le premier test qui appelait le classifieur, depassant le timeout Mocha de 10s par test. Fixe par un root hook plugin Mocha (test-support/mocha-root-hooks.ts, .mocharc.json) qui reset la DB et warm up le classifieur une seule fois avant toute suite, avec son propre timeout de 60s. Verifie : 27/27 tests pytest du service (dont le nouveau test de non-regression), lint + build complets du monorepo. La suite Mocha elle-meme n'a toujours pas pu etre executee dans cet environnement (pas de Postgres disponible ici) — a confirmer via la CI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * ci(temp): ajoute un run de calibrate-tech-step-threshold.ts pour observation Etape temporaire pour lire le sweep de seuils de confiance contre le vrai service tech-step-intent-service en CI (aucun Postgres/service disponible localement dans cette session) — sera retiree une fois CONFIDENCE_THRESHOLD recalibre dans tech-step-matcher.ts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): recalibre CONFIDENCE_THRESHOLD pour le nouveau classifieur spaCy 0.75 (calibre a l'origine contre node-nlp) laissait de vrais verdicts corrects sur des clauses sans ancre NER (rien sur quoi retomber) sous le seuil : melt scorait 0.68 sur "jusqu'a ce que le beurre ait disparu dans la poele" (le cas motivant tout ce pipeline), preheat 0.52 sur "mettre la poele sur feu vif" — tous deux corrects, tous deux rejetes a 0.75. Recalibre a 0.45 : marge confortable au-dessus du bruit (texte anglais via le classifieur francais score ~0.04, indiscernable du hasard sur ~26 classes) et sous les deux cas ci-dessus. Confirme par calibrate-tech-step-threshold.ts contre TECH_STEP_EVAL_DATASET (F1 plafonne a 0.987 des 0.45, reste plat jusqu'a 0.95 — 0.45 est deja le seuil le plus bas qui capture tout le gain disponible). Retire l'etape CI temporaire de calibration (ci.yml) une fois la valeur choisie. Verifie : lint + build complets du monorepo, 27/27 pytest du service, sweep de seuils + verification manuelle contre le corpus reel en local (services Python, sans Postgres) et en CI. La suite Mocha complete reste a confirmer sur ce commit (executee en CI, pas localement — pas de Postgres disponible dans cet environnement). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): entraine le textcat plus longtemps pour une confiance reelle Cause racine du dernier test Mocha en echec (getAuditBatch flaguait "Faire mijoter a feu doux" comme peu fiable malgre une ancre NER claire) : avec seulement 30 iterations/dropout 0.2, le textcat retournait le bon intent (argmax correct) mais avec une confiance tres basse et compressee (0.2-0.7 sur l'ensemble du corpus reel, y compris des cas evidents) — un vrai probleme de qualite d'entrainement, pas seulement de seuil. 150 iterations / lot de 16 / dropout 0.1 (mesure localement contre le vrai corpus, sans Postgres) : melt ~0.95, preheat ~0.90, jusqu'a ~0.51 pour le cas le plus faible observe (bake), bruit hors-vocabulaire toujours ~0.05. ~110s d'entrainement par locale (~220s pour fr+en au warm-up) — compromis assume et documente (README du service, commentaires du code), contrairement a l'entrainement quasi instantane de node-nlp. Root hook Mocha (mocha-root-hooks.ts) et sa doc mis a jour avec un timeout de 600s pour couvrir cette duree avec marge. Verifie : 27/27 pytest, lint + build complets du monorepo. Suite Mocha a confirmer sur ce commit via CI (source du diagnostic qui a mene a ce fix). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(recipes): journalise chaque input/output du pipeline NLP Ajoute un logging JSON structure (meme convention que LoggerService cote apps/api) a services/tech-step-intent-service : chaque appel POST /v1/process journalise locale/texte en entree et entites/intent/score en sortie, chaque POST /v1/train journalise les uid entraines et les compteurs resultants. Chatter interne de spaCy mis a WARNING pour ne pas noyer ces lignes. Bug trouve et corrige en verifiant les octets bruts d'un log reel (pas juste son affichage terminal) : l'encodage par defaut de sys.stdout sur Windows produisait de vrais octets UTF-8 invalides pour tout texte accentue journalise (le francais des etapes de recette) — corrige par sys.stdout.reconfigure(encoding="utf-8") au demarrage. LOG_LEVEL configurable (INFO par defaut), documente dans le README du service et .env.example. Verifie : 30/30 pytest (3 nouveaux tests sur le formateur JSON), smoke test HTTP reel confirmant au niveau des octets que les caracteres accentues sont preserves, lint complet du monorepo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(recipes): rapatrie le corpus NLP cote Python et l'enrichit de 48 techniques Changement d'architecture demande par l'utilisateur : le dataset d'entrainement (TECH_STEP_TRAINING_DATA) quitte apps/api pour vivre entierement dans services/tech-step-intent-service (intent_service/training_data.py). Ce service est desormais autonome : il s'entraine lui-meme une seule fois, a son propre demarrage (PipelineRegistry.initialize, dans le lifespan FastAPI), sans plus dependre d'un POST /v1/train pousse par apps/api (route supprimee). apps/api ne connait plus aucune technique/synonyme, uniquement le resultat de POST /v1/process. Corpus enrichi avec les 48 techniques du lexique fourni (Arroser, Appertiser, Braiser, Caraméliser, Confire, Julienne/Brunoise/Mirepoix/ Paysanne, Cuire à blanc/au bain-marie/à l'étouffée, Déglacer variantes, Emulsionner, Glacer, Pocher, Réduire, Suer, Zester, etc.), soit 74 techniques au total (26 + 48). Integration complete bout en bout : - reference-seed-data.ts : 48 nouvelles entrees TECH_STEPS - apps/web/locales/fr/translation.json : libelles francais correspondants - "Mitonner" fondu comme synonyme de simmer (pas une technique distincte, sa propre definition le dit) - "Blanchir un oeuf" (whiskPale) distingue de "Blanchir un legume" (blanch, existant) via des synonymes en phrase complete plutot qu'au mot nu — filter_spans (deja en place) resout la collision par specificite Impact performance mesure : le corpus elargi (74 classes vs 26) rend l'entrainement bien plus lent a nombre d'iterations egal (150 iterations depassait 17 minutes par run de test) — reduit a 40 iterations apres mesures repetees en local (~200s/locale, ~400s pour fr+en combines). docker-compose.yml (healthcheck start_period 600s), CI (timeout curl 600s) et le README du service documentent ce nouveau temps de demarrage. CONFIDENCE_THRESHOLD recalibre a 0.2 par verification manuelle (0.75 puis 0.45 ne tenaient plus compte tenu du nombre de classes) — marque explicitement comme placeholder en attendant une vraie repasse de calibrate-tech-step-threshold.ts (necessite Postgres, indisponible dans cet environnement). Verifie : 28/28 tests pytest du service (suite complete re-ecrite pour s'entrainer une seule fois par session sur le vrai corpus, fixture partagee dans conftest.py), lint + build complets du monorepo. La suite Mocha d'apps/api reste a confirmer via CI (le root hook mocha n'attend plus l'entrainement, seulement CI's propre attente sur /health). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): corrige l'assertion de taille du catalogue TechStep en dur test/reference.test.ts attendait exactement 26 techniques (l'ancien catalogue) au lieu de deriver la longueur attendue de TECH_STEPS (reference-seed-data.ts) — trouve par la CI apres l'ajout des 48 nouvelles techniques (74 au total). Seul echec du run CI precedent, le service Python (nouveau corpus, self-training) a lui demarre et repondu correctement dans le nouveau delai imparti. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): entraine le textcat plus longtemps pour une confiance reelle Suite a une revue de code sur locale_pipeline.py, trois ameliorations implementees et verifiees contre le vrai corpus (74 techniques) : - spacy.util.fix_random_seed(_TRAINING_SEED) avant nlp.initialize() — random.Random() ne graine que l'ordre de melange des exemples, pas l'init des poids/dropout internes de thinc. - _DiacriticsNormalizer deplace au-dessus de sa factory @Language.factory — plus d'annotation de type en chaine. - Log explicite (logger.warning) quand train() recoit moins de 2 labels et saute la creation du textcat, plus une clarification de la docstring de process() sur les deux cas menant a intent=None. - Early stopping avec suivi de la perte par epoque, _TRAINING_ITERATIONS restant le plafond. Mesure sur le vrai corpus : ne se declenche jamais dans le budget actuel de 40 iterations (la perte continue de baisser significativement jusqu'au bout) — documente honnetement comme filet de securite pour un futur relevement du plafond, pas un gain de temps aujourd'hui. Deux suggestions de la revue examinees et non retenues, avec justification en commentaire : le risque de desalignement pattern/texte via normalize_text (normalize_text opere par token deja tokenise, jamais sur la chaine brute — pas de risque de segmentation differente) ; passer a attr="LOWER" aurait au contraire regresse l'insensibilite aux accents que attr="NORM" fournit deliberement. Verifie : 28/28 pytest (dont le vrai corpus complet via la fixture partagee), lint du monorepo. Temps d'entrainement mesure stable (~200-230s/locale, dans la marge de bruit deja documentee). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(recipes): entraine le textcat sur les synonymes en plus des utterances Suite a une suggestion de revue de code : le textcat n'apprenait jusqu'ici que sur entry.utterances, jamais sur entry.synonyms (deja utilises pour le PhraseMatcher). Ajouter le mot-cle isole comme exemple positif de sa propre technique ameliore radicalement la confiance sur les cas ancres sans paraphrase entrainee. Mesures sur le vrai corpus (74 techniques) : - 40 iterations + synonymes (749 exemples vs 286 avant) : gain de confiance massif (simmer 0.25->0.60, cook 0.33->0.60, bake 0.34->0.86) mais temps d'entrainement multiplie par 2.6 (~535s/locale, ~17min combine pour fr+en — inacceptable). - 15 iterations + synonymes : retour a un temps raisonnable (~205s) mais qualite pire qu'avant (simmer/cook repassent sous le seuil de confiance) — les exemples supplementaires ne compensent pas la perte d'epoques a ce point. - 25 iterations + synonymes (retenu) : ~336s/locale (~670s combine), meilleur compromis — tous les cas mesures s'ameliorent par rapport a la config precedente (simmer 0.25->0.31, cook 0.33->0.38, bake 0.34->0.62, zest 0.64->0.66, julienne 0.56->0.76, compote 0.76->0.78), bruit hors-vocabulaire toujours negligeable (~0.02). CONFIDENCE_THRESHOLD releve de 0.2 a 0.25 (le cas le plus faible mesure est maintenant 0.31, avec plus de marge qu'avant). docker-compose.yml (start_period 900s) et la CI (timeout 900s) ajustes pour le nouveau temps de demarrage (~11 min pour fr+en combines, contre ~7 min avant). Deux autres pistes de la meme revue examinees et non retenues avec justification : classe __OTHER__/negatifs hors-domaine (le bruit mesure est deja bas, ~0.02, sans le symptome que cette classe corrige) et boost de score post-traitement si le NER confirme l'intention predite (casserait la garantie "score brut, jamais corrige par l'ancre" que services/tech-step-llm-worker's audit de faible confiance depend explicitement d'avoir, voir le commentaire de TechStepClauseClassification dans tech-step-matcher.ts). Verifie : 28/28 pytest (dont le vrai corpus complet, ~10.5 min pour la suite complete), lint + build du monorepo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
812 lines
36 KiB
Text
812 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 spaCy-based model (`services/tech-step-intent-service`) 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
|
|
/// that service'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.
|
|
///
|
|
/// `source` distinguishes a `"manual"` row — written immediately when a
|
|
/// user submits a `StepTechStepCorrection` that asserts a technique
|
|
/// (`recipe-tech-step-correction.service.ts`'s `applyManualCorrection`),
|
|
/// not just recorded as a pending suggestion — from an `"auto"` row the
|
|
/// classifier itself produced (`tech-step-matcher.ts`). Both kinds coexist
|
|
/// in the same ordered sequence; the detail view (`apps/web`) renders them
|
|
/// with a different highlight color so a viewer can tell which is which.
|
|
/// `backfillTechSteps` (`scripts/backfill-tech-steps.ts`) only ever
|
|
/// deletes/recreates `"auto"` rows — a `"manual"` row survives a
|
|
/// classifier/corpus change until a user (or a future moderation feature)
|
|
/// explicitly changes it again.
|
|
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")
|
|
source String @default("auto")
|
|
|
|
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")
|
|
}
|