Compare commits

...

21 commits

Author SHA1 Message Date
cc4278ec68 Actualiser apps/web/package.json
Some checks are pending
CI / test (push) Waiting to run
CI / intent-service-test (push) Waiting to run
CI / build (push) Waiting to run
CI / e2e (push) Waiting to run
CI / lint (push) Successful in 1m51s
2026-08-27 21:11:10 +02:00
9ec9b82e06 Actualiser .github/workflows/ci.yml
Some checks failed
CI / lint (push) Successful in 43s
CI / test (push) Successful in 14m18s
CI / intent-service-test (push) Successful in 7m49s
CI / build (push) Successful in 1m57s
CI / e2e (push) Failing after 6m6s
2026-08-27 20:25:50 +02:00
f7aa696f55 Actualiser .github/workflows/ci.yml
Some checks failed
CI / lint (push) Successful in 50s
CI / test (push) Successful in 14m2s
CI / intent-service-test (push) Successful in 7m20s
CI / build (push) Successful in 55s
CI / e2e (push) Failing after 5m58s
2026-08-27 19:32:53 +02:00
f19365e20e Actualiser .github/workflows/ci.yml
Some checks failed
CI / lint (push) Successful in 45s
CI / test (push) Failing after 7m40s
CI / intent-service-test (push) Successful in 7m27s
CI / build (push) Successful in 56s
CI / e2e (push) Failing after 6m41s
2026-08-27 18:55:05 +02:00
8a51f0bd6c Actualiser .github/workflows/ci.yml
Some checks failed
CI / build (push) Waiting to run
CI / e2e (push) Waiting to run
CI / lint (push) Successful in 1m41s
CI / test (push) Failing after 8m25s
CI / intent-service-test (push) Has been cancelled
2026-08-27 18:43:38 +02:00
32c7ed48c6 Actualiser .github/workflows/ci.yml
Some checks failed
CI / lint (push) Successful in 41s
CI / test (push) Failing after 1m2s
CI / intent-service-test (push) Failing after 3s
CI / build (push) Successful in 54s
CI / e2e (push) Has been cancelled
2026-08-27 18:35:36 +02:00
0ab30a4588 Actualiser .github/workflows/ci.yml
Some checks failed
CI / lint (push) Successful in 47s
CI / test (push) Failing after 8s
CI / intent-service-test (push) Failing after 3s
CI / build (push) Successful in 59s
CI / e2e (push) Has been cancelled
2026-08-27 18:26:06 +02:00
3c04efd16f Actualiser .github/workflows/ci.yml
Some checks failed
CI / e2e (push) Waiting to run
CI / lint (push) Successful in 45s
CI / test (push) Failing after 8s
CI / intent-service-test (push) Failing after 3s
CI / build (push) Has been cancelled
2026-08-27 18:19:10 +02:00
7c423cc0fd Actualiser .github/workflows/ci.yml
Some checks failed
CI / lint (push) Successful in 46s
CI / test (push) Failing after 6s
CI / intent-service-test (push) Failing after 3s
CI / build (push) Successful in 57s
CI / e2e (push) Has been cancelled
2026-08-27 18:13:56 +02:00
d58491e6f9 Merge pull request 'feat(recipes): fusionne l'édition de la technique et des métadonnées dans le correcteur' (#6) from feat/tech-step-correction-merged-editor into main
Some checks failed
CI / lint (push) Successful in 1m38s
CI / test (push) Failing after 2s
CI / intent-service-test (push) Failing after 5s
CI / build (push) Successful in 51s
CI / e2e (push) Failing after 5m57s
Reviewed-on: #6
2026-08-27 17:41:01 +02:00
478914787f feat(recipes): fusionne l'edition de la technique et des metadonnees
Deux retours utilisateur distincts, meme cause : le popover de correction
presentait la selection de technique et l'edition des ingredients/
ustensiles comme deux etapes successives (liste plate -> "Valider" pour
la selection, puis un ecran separe pour les metadonnees), et cette liste
de techniques elle-meme n'etait qu'un flot de ~74 boutons sans recherche
ni tri, illisible en pratique.

TechStepCorrectionPopover.tsx : les deux fonctions fusionnent en un seul
ecran — la technique se choisit desormais via le meme CatalogSearchPicker
(recherche + liste filtrée) deja utilise pour les ingredients/ustensiles,
avec le choix courant marque visuellement (nouveau prop `selectedId`), et
les sections Ingredients/Ustensiles restent affichees en permanence a cote
plutot que masquees tant qu'aucune technique n'est choisie. "Valider" reste
desactive tant qu'aucune technique n'est selectionnee.

CatalogSearchPicker.tsx : nouveau prop optionnel `selectedId` pour marquer
visuellement l'item courant dans la liste (utilise par le picker de
technique, pas par les sous-flux ingredient/ustensile qui n'ont pas de
notion de "choix courant").

Tests Cypress (component + e2e) et traductions mis a jour pour ce nouvel
ecran unique.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 08:30:59 +02:00
5ab9832131 fix(recipes): pre-selectionne la technique deja detectee dans le correcteur
Ouvrir le popover de correction depuis un highlight existant tombait sur
la liste de choix (avec l'option "Aucune technique ici"), pas sur la vue
metadonnees — pour voir/editer les ingredients/ustensiles deja rattaches
a une technique correctement detectee, il fallait recliquer cette meme
technique dans la liste, sans aucun indice que c'est ce qu'il fallait
faire (rien ne la distingue des autres dans cette liste). Resultat cote
utilisateur : la fonctionnalite de correction/edition de metadonnees
etait techniquement presente mais invisible en pratique.

TechStepCorrectionPopover demarre desormais selectionne sur
`previousTechStepId` quand il est defini (un clic sur un highlight
existant) — droit dans la vue Ingredients/Ustensiles, deja pre-remplie.
"Changer" reste disponible pour rejoindre la liste complete (relabelliser
ou supprimer la correspondance).

Tests Cypress (component + e2e) mis a jour pour ce nouveau point d'entree
par defaut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 08:13:41 +02:00
kyuno053
eef5db92b5
feat(recipes): permet d'associer ingredients/ustensiles a une correction de technique
Etend le flux de correction existant (TechStepCorrectionPopover) pour que
l'utilisateur associe lui-meme des ingredients (avec quantite/unite) et
des ustensiles a la technique qu'il corrige, avec le meme marquage
source: "manual" que la technique elle-meme.

Backend :
- submitTechStepCorrectionSchema (packages/shared) accepte des tableaux
  ingredients/utensils optionnels, chacun avec son propre span [start,end)
  selectionne par l'utilisateur. Omis = ne touche pas aux metadonnees
  existantes ; tableau (meme vide) = remplace tout ce qui existait sur
  cette occurrence (auto ET manuel precedent - decision validee avec
  l'utilisateur).
- applyManualCorrection (recipe-tech-step-correction.service.ts) ecrit
  les nouvelles lignes StepTechStepIngredient/StepTechStepUtensil apres
  avoir vide celles de l'occurrence via deleteMany - meme chemin de code
  que ce soit une creation ou une mise a jour de la technique.
- Nouveaux asserts d'existence (ingredient/unite/ustensile) + validation
  de span, nouveau code d'erreur UTENSIL_NOT_FOUND.
- source ajoute a StepTechStepIngredientView/StepTechStepUtensilView
  (le calque manquait ce que la colonne DB portait deja).

Frontend :
- TechStepCorrectionPopover passe d'un clic = soumission immediate a un
  flux selection-puis-confirmation, avec deux nouvelles sections
  Ingredients/Ustensiles pre-remplies avec l'existant.
- Ajouter un ingredient/ustensile demande une selection de texte dediee
  dans la description encore visible (StepDescription geree via un
  nouvel etat pendingSpanRequest/resolvedMetadataSpan) - pas de raccourci
  sur le span de la correction elle-meme.
- Nouveau CatalogSearchPicker.tsx, plus leger que IngredientPicker pour
  ce contexte de popover, reutilise pour les deux catalogues.
- getUtensils() ajoute a apiClient.

Tests : nouveaux cas Mocha (attache/remplace/omission/validations) dans
recipe-tech-step-correction.test.ts, TechStepCorrectionPopover.cy.tsx
etendu avec le nouveau flux, recipes.ts (e2e) ajuste au clic Valider
supplementaire.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:51:45 +02:00
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
kyuno053
bf58834aa9
feat(recipes): migre la detection des tech steps de node-nlp vers un microservice Python spaCy
* feat(recipes): migre la detection des tech steps de node-nlp vers un microservice Python spaCy

Remplace TechStepClassifierService's node-nlp (NlpManager) par
services/tech-step-intent-service, un microservice FastAPI/spaCy dedie
(PhraseMatcher pour le NER par synonymes, textcat pour la classification
d'intention). Corpus (TECH_STEP_TRAINING_DATA) toujours possede par
apps/api, pousse au service via POST /v1/train a chaque warm-up ; le
service ne touche jamais Postgres (meme posture que
services/tech-step-llm-worker).

Cote apps/api :
- intent-service-client.ts : client HTTP vers le nouveau service
- tech-step-matcher.ts : delegue NER + intent classification au client,
  logique pure (splitIntoClauses, seuil/fallback) inchangee
- env.ts : INTENT_SERVICE_BASE_URL/INTENT_SERVICE_SECRET (secret requis,
  service coeur non optionnel)
- server.ts : warm-up avec retry/backoff (service Python demarre a part)
- scripts/calibrate-tech-step-threshold.ts : recalibration empirique de
  CONFIDENCE_THRESHOLD contre le jeu d'eval existant
- node-nlp retire (package.json, node-nlp.d.ts, model.nlp du .gitignore)

docker-compose.yml : nouveau service tech-step-intent-service (pas de
port expose, healthcheck, app en depend). CI : job intent-service-test
(pytest) + le job test demarre le service en arriere-plan avant la suite
Mocha (jamais de mock d'un service interne, cf specs/dev-conventions.md).

Verifie : 26/26 tests pytest du service (dont les offsets caracteres
exacts de tech-step-matcher.test.ts), lint + build complets du monorepo,
smoke test HTTP reel bout en bout. La suite Mocha et docker compose
build/up n'ont pas pu etre executes dans cet environnement (pas de
Postgres/Docker disponibles ici) — a confirmer via la CI et en local.

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

* fix(recipes): corrige les matches dupliques et le timeout de warm-up des tests CI

Deux bugs reels trouves par la premiere execution CI de la migration
node-nlp -> tech-step-intent-service :

1. PhraseMatcher retourne tous les matches y compris chevauchants — un
   synonyme comme "fondre" litteralement contenu dans "faire fondre" (tous
   deux synonymes de `melt`) produisait deux candidats separes pour la meme
   technique, dupliquant son techStepId dans le resultat final. Fixe avec
   spacy.util.filter_spans (garde le plus long match par position) dans
   LocalePipeline.process. Test de non-regression ajoute.

2. La suite Mocha construit `app` directement via createApp(), sans jamais
   passer par server.ts — le warm-up (POST /v1/train fr+en sur le corpus
   complet) se declenchait donc paresseusement dans le premier test qui
   appelait le classifieur, depassant le timeout Mocha de 10s par test.
   Fixe par un root hook plugin Mocha (test-support/mocha-root-hooks.ts,
   .mocharc.json) qui reset la DB et warm up le classifieur une seule fois
   avant toute suite, avec son propre timeout de 60s.

Verifie : 27/27 tests pytest du service (dont le nouveau test de
non-regression), lint + build complets du monorepo. La suite Mocha
elle-meme n'a toujours pas pu etre executee dans cet environnement (pas de
Postgres disponible ici) — a confirmer via la CI.

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

* ci(temp): ajoute un run de calibrate-tech-step-threshold.ts pour observation

Etape temporaire pour lire le sweep de seuils de confiance contre le vrai
service tech-step-intent-service en CI (aucun Postgres/service disponible
localement dans cette session) — sera retiree une fois CONFIDENCE_THRESHOLD
recalibre dans tech-step-matcher.ts.

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

* fix(recipes): recalibre CONFIDENCE_THRESHOLD pour le nouveau classifieur spaCy

0.75 (calibre a l'origine contre node-nlp) laissait de vrais verdicts
corrects sur des clauses sans ancre NER (rien sur quoi retomber) sous le
seuil : melt scorait 0.68 sur "jusqu'a ce que le beurre ait disparu dans
la poele" (le cas motivant tout ce pipeline), preheat 0.52 sur "mettre la
poele sur feu vif" — tous deux corrects, tous deux rejetes a 0.75.

Recalibre a 0.45 : marge confortable au-dessus du bruit (texte anglais
via le classifieur francais score ~0.04, indiscernable du hasard sur ~26
classes) et sous les deux cas ci-dessus. Confirme par
calibrate-tech-step-threshold.ts contre TECH_STEP_EVAL_DATASET (F1
plafonne a 0.987 des 0.45, reste plat jusqu'a 0.95 — 0.45 est deja le
seuil le plus bas qui capture tout le gain disponible).

Retire l'etape CI temporaire de calibration (ci.yml) une fois la valeur
choisie.

Verifie : lint + build complets du monorepo, 27/27 pytest du service,
sweep de seuils + verification manuelle contre le corpus reel en local
(services Python, sans Postgres) et en CI. La suite Mocha complete reste
a confirmer sur ce commit (executee en CI, pas localement — pas de
Postgres disponible dans cet environnement).

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

* fix(recipes): entraine le textcat plus longtemps pour une confiance reelle

Cause racine du dernier test Mocha en echec (getAuditBatch flaguait
"Faire mijoter a feu doux" comme peu fiable malgre une ancre NER claire) :
avec seulement 30 iterations/dropout 0.2, le textcat retournait le bon
intent (argmax correct) mais avec une confiance tres basse et compressee
(0.2-0.7 sur l'ensemble du corpus reel, y compris des cas evidents) —
un vrai probleme de qualite d'entrainement, pas seulement de seuil.

150 iterations / lot de 16 / dropout 0.1 (mesure localement contre le
vrai corpus, sans Postgres) : melt ~0.95, preheat ~0.90, jusqu'a ~0.51
pour le cas le plus faible observe (bake), bruit hors-vocabulaire toujours
~0.05. ~110s d'entrainement par locale (~220s pour fr+en au warm-up) —
compromis assume et documente (README du service, commentaires du code),
contrairement a l'entrainement quasi instantane de node-nlp.

Root hook Mocha (mocha-root-hooks.ts) et sa doc mis a jour avec un timeout
de 600s pour couvrir cette duree avec marge.

Verifie : 27/27 pytest, lint + build complets du monorepo. Suite Mocha a
confirmer sur ce commit via CI (source du diagnostic qui a mene a ce fix).

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

* feat(recipes): journalise chaque input/output du pipeline NLP

Ajoute un logging JSON structure (meme convention que LoggerService cote
apps/api) a services/tech-step-intent-service : chaque appel
POST /v1/process journalise locale/texte en entree et
entites/intent/score en sortie, chaque POST /v1/train journalise les uid
entraines et les compteurs resultants. Chatter interne de spaCy mis a
WARNING pour ne pas noyer ces lignes.

Bug trouve et corrige en verifiant les octets bruts d'un log reel (pas
juste son affichage terminal) : l'encodage par defaut de sys.stdout sur
Windows produisait de vrais octets UTF-8 invalides pour tout texte
accentue journalise (le francais des etapes de recette) — corrige par
sys.stdout.reconfigure(encoding="utf-8") au demarrage.

LOG_LEVEL configurable (INFO par defaut), documente dans le README du
service et .env.example.

Verifie : 30/30 pytest (3 nouveaux tests sur le formateur JSON), smoke
test HTTP reel confirmant au niveau des octets que les caracteres
accentues sont preserves, lint complet du monorepo.

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

* feat(recipes): rapatrie le corpus NLP cote Python et l'enrichit de 48 techniques

Changement d'architecture demande par l'utilisateur : le dataset
d'entrainement (TECH_STEP_TRAINING_DATA) quitte apps/api pour vivre
entierement dans services/tech-step-intent-service
(intent_service/training_data.py). Ce service est desormais autonome :
il s'entraine lui-meme une seule fois, a son propre demarrage
(PipelineRegistry.initialize, dans le lifespan FastAPI), sans plus
dependre d'un POST /v1/train pousse par apps/api (route supprimee).
apps/api ne connait plus aucune technique/synonyme, uniquement le
resultat de POST /v1/process.

Corpus enrichi avec les 48 techniques du lexique fourni (Arroser,
Appertiser, Braiser, Caraméliser, Confire, Julienne/Brunoise/Mirepoix/
Paysanne, Cuire à blanc/au bain-marie/à l'étouffée, Déglacer variantes,
Emulsionner, Glacer, Pocher, Réduire, Suer, Zester, etc.), soit 74
techniques au total (26 + 48). Integration complete bout en bout :
- reference-seed-data.ts : 48 nouvelles entrees TECH_STEPS
- apps/web/locales/fr/translation.json : libelles francais correspondants
- "Mitonner" fondu comme synonyme de simmer (pas une technique distincte,
  sa propre definition le dit)
- "Blanchir un oeuf" (whiskPale) distingue de "Blanchir un legume"
  (blanch, existant) via des synonymes en phrase complete plutot qu'au
  mot nu — filter_spans (deja en place) resout la collision par
  specificite

Impact performance mesure : le corpus elargi (74 classes vs 26) rend
l'entrainement bien plus lent a nombre d'iterations egal (150 iterations
depassait 17 minutes par run de test) — reduit a 40 iterations apres
mesures repetees en local (~200s/locale, ~400s pour fr+en combines).
docker-compose.yml (healthcheck start_period 600s), CI (timeout curl
600s) et le README du service documentent ce nouveau temps de demarrage.
CONFIDENCE_THRESHOLD recalibre a 0.2 par verification manuelle (0.75 puis
0.45 ne tenaient plus compte tenu du nombre de classes) — marque
explicitement comme placeholder en attendant une vraie repasse de
calibrate-tech-step-threshold.ts (necessite Postgres, indisponible dans
cet environnement).

Verifie : 28/28 tests pytest du service (suite complete re-ecrite pour
s'entrainer une seule fois par session sur le vrai corpus, fixture
partagee dans conftest.py), lint + build complets du monorepo. La suite
Mocha d'apps/api reste a confirmer via CI (le root hook mocha n'attend
plus l'entrainement, seulement CI's propre attente sur /health).

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

* fix(recipes): corrige l'assertion de taille du catalogue TechStep en dur

test/reference.test.ts attendait exactement 26 techniques (l'ancien
catalogue) au lieu de deriver la longueur attendue de TECH_STEPS
(reference-seed-data.ts) — trouve par la CI apres l'ajout des 48
nouvelles techniques (74 au total). Seul echec du run CI precedent, le
service Python (nouveau corpus, self-training) a lui demarre et repondu
correctement dans le nouveau delai imparti.

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

* fix(recipes): entraine le textcat plus longtemps pour une confiance reelle

Suite a une revue de code sur locale_pipeline.py, trois ameliorations
implementees et verifiees contre le vrai corpus (74 techniques) :

- spacy.util.fix_random_seed(_TRAINING_SEED) avant nlp.initialize() —
  random.Random() ne graine que l'ordre de melange des exemples, pas
  l'init des poids/dropout internes de thinc.
- _DiacriticsNormalizer deplace au-dessus de sa factory @Language.factory
  — plus d'annotation de type en chaine.
- Log explicite (logger.warning) quand train() recoit moins de 2 labels
  et saute la creation du textcat, plus une clarification de la docstring
  de process() sur les deux cas menant a intent=None.
- Early stopping avec suivi de la perte par epoque, _TRAINING_ITERATIONS
  restant le plafond. Mesure sur le vrai corpus : ne se declenche jamais
  dans le budget actuel de 40 iterations (la perte continue de baisser
  significativement jusqu'au bout) — documente honnetement comme filet
  de securite pour un futur relevement du plafond, pas un gain de temps
  aujourd'hui.

Deux suggestions de la revue examinees et non retenues, avec
justification en commentaire : le risque de desalignement pattern/texte
via normalize_text (normalize_text opere par token deja tokenise, jamais
sur la chaine brute — pas de risque de segmentation differente) ; passer
a attr="LOWER" aurait au contraire regresse l'insensibilite aux accents
que attr="NORM" fournit deliberement.

Verifie : 28/28 pytest (dont le vrai corpus complet via la fixture
partagee), lint du monorepo. Temps d'entrainement mesure stable
(~200-230s/locale, dans la marge de bruit deja documentee).

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

* fix(recipes): entraine le textcat sur les synonymes en plus des utterances

Suite a une suggestion de revue de code : le textcat n'apprenait
jusqu'ici que sur entry.utterances, jamais sur entry.synonyms (deja
utilises pour le PhraseMatcher). Ajouter le mot-cle isole comme exemple
positif de sa propre technique ameliore radicalement la confiance sur
les cas ancres sans paraphrase entrainee.

Mesures sur le vrai corpus (74 techniques) :
- 40 iterations + synonymes (749 exemples vs 286 avant) : gain de
  confiance massif (simmer 0.25->0.60, cook 0.33->0.60, bake 0.34->0.86)
  mais temps d'entrainement multiplie par 2.6 (~535s/locale, ~17min
  combine pour fr+en — inacceptable).
- 15 iterations + synonymes : retour a un temps raisonnable (~205s) mais
  qualite pire qu'avant (simmer/cook repassent sous le seuil de
  confiance) — les exemples supplementaires ne compensent pas la perte
  d'epoques a ce point.
- 25 iterations + synonymes (retenu) : ~336s/locale (~670s combine),
  meilleur compromis — tous les cas mesures s'ameliorent par rapport a
  la config precedente (simmer 0.25->0.31, cook 0.33->0.38,
  bake 0.34->0.62, zest 0.64->0.66, julienne 0.56->0.76, compote
  0.76->0.78), bruit hors-vocabulaire toujours negligeable (~0.02).

CONFIDENCE_THRESHOLD releve de 0.2 a 0.25 (le cas le plus faible mesure
est maintenant 0.31, avec plus de marge qu'avant). docker-compose.yml
(start_period 900s) et la CI (timeout 900s) ajustes pour le nouveau
temps de demarrage (~11 min pour fr+en combines, contre ~7 min avant).

Deux autres pistes de la meme revue examinees et non retenues avec
justification : classe __OTHER__/negatifs hors-domaine (le bruit mesure
est deja bas, ~0.02, sans le symptome que cette classe corrige) et boost
de score post-traitement si le NER confirme l'intention predite (casserait
la garantie "score brut, jamais corrige par l'ancre" que
services/tech-step-llm-worker's audit de faible confiance depend
explicitement d'avoir, voir le commentaire de TechStepClauseClassification
dans tech-step-matcher.ts).

Verifie : 28/28 pytest (dont le vrai corpus complet, ~10.5 min pour la
suite complete), lint + build du monorepo.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 09:03:52 +02:00
kyuno053
109dde9c7b
feat(shopping-list): liste de courses agrégée depuis le planning (#73)
GET /shopping-list?date= (shopping-list.service.ts/.routes.ts) somme les
ingrédients de chaque recette planifiée sur la semaine, mis à l'échelle par
les portions de chaque créneau (PlanningItem.portions / Recipe.portions),
regroupés par paire (ingredientId, unitId) — jamais null contrairement à
GET /planning, une semaine vide redescend en items: [].

Côté web, ShoppingListPage rend cette liste groupée par rayon (même
IngredientCategory que IngredientPicker), triée alphabétiquement en
français à l'intérieur d'un rayon (shopping-list.ts, logique pure extraite
du composant). WeekNavigator (flèches + calendrier) est extrait de
PlanningPage vers features/planning/ pour être partagé entre les deux
pages ; ses libellés migrent de planning.* vers common.weekNav.*/
common.calendar.*/common.days.*, plus génériques pour une page qui n'est
plus seulement le planning.

ComingSoonPage retiré (plus aucun appelant, Liste de courses avait le
dernier stub restant).

Tests : Mocha (agrégation, mise à l'échelle par portions, unités non
fusionnées) + Cucumber (shopping-list.feature : liste vide, groupement/tri,
navigation de semaine) + mise à jour de layout.cy.ts/planning-page.cy.ts
pour le nouveau rendu.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:45:06 +02:00
kyuno053
520e539fe6
fix(recipes): active le matching ingredients/unites pour les sources en francais (#72)
Cause racine du signalement "beaucoup d'ingredients ne sont pas linkes,
de meme pour les unites et les quantites" sur Marmiton/750g/Manger
Bouger : translateRecipe (recipe-translation.ts) ET previewSourceItem
(sources.service.ts) sautaient integralement loadIngredientCatalog/
loadUnitCatalog/translateRecipeIngredients des que locale !== "en" —
aucune tentative de matching n'etait jamais faite pour une source
francaise, pas un probleme de qualite de matching. Les trois sources
ajoutees dans cette session sont toutes locale: "fr".

Corrige en trois temps :

- packages/shared/src/data/catalog-labels-fr.ts (nouveau) :
  INGREDIENT_LABELS_FR (554 entrees, copiees depuis
  apps/web/src/locales/fr/translation.json qui les avait deja pour
  l'UI — pas une nouvelle redaction), INGREDIENT_LABEL_SYNONYMS_FR
  (mecanisme existant, pour patcher au cas par cas les libelles dont le
  phrasage "affichage" ne correspond pas a l'ordre naturel d'un texte
  de recette — ex. vanillaBean), UNIT_LABELS_FR (17 entrees,
  redigees a la main comme UNIT_LABELS_EN — abreviations/variantes
  reellement utilisees en francais : cuillere a soupe/cas/c.a.s...).

- ingredient-matcher.ts : stemWord se scinde en stemWordEn/stemWordFr
  (locale parametrable, defaut "en" pour ne rien casser) — le stemmer
  anglais appliquait sa regle "es" -> "" a des pluriels francais
  reguliers ("carottes" -> "carott" au lieu de "carotte"), cassant
  silencieusement le matching pour la quasi-totalite des ingredients
  francais dont le singulier se termine par une voyelle. matchUnit est
  reecrit pour chercher une sous-sequence ordonnee (comme
  matchIngredientName) plutot qu'une egalite du seul premier mot : un
  synonyme francais peut etre multi-mots ("cuillere a soupe"), une
  phrase entiere ne pouvant jamais egaler un seul mot extrait.
  loadIngredientCatalog/loadUnitCatalog prennent un parametre locale.

- recipe-translation.ts/sources.service.ts : suppression du
  if (locale !== "en") qui court-circuitait tout — les catalogues sont
  desormais toujours charges avec la locale de la source ; une locale
  sans table de libelles recoit simplement des catalogues vides (degrade
  gracieusement, ne plante pas).

Tests : 14 nouveaux tests purs (matchIngredientName/matchUnit fr,
stemmer, regression), 3 nouveaux tests DB (loadIngredientCatalog/
loadUnitCatalog fr + locale inconnue), 3 nouveaux tests
recipe-translation remplacant un test qui figeait l'ancien comportement
cassé, 1 nouveau test d'integration HTTP (sources.test.ts) avec un
adaptateur factice francais bout en bout. Les tests DB n'ont pas pu
etre executes localement (pas de Postgres/Docker dans cet environnement
sandbox) — a verifier en CI.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:34:32 +02:00
kyuno053
ba3c978c25
feat(recipes): scroll infini + placeholders sur le parcours des sources externes (#71)
* fix(recipes): corrige la liste vide de sevenFiftyGAdapter quand le filtre est vide

L'endpoint IA que list() utilisait pour toute recherche (SEARCH_URL,
/genius/query/) répond avec un corps de réponse vide dès que query est
vide — vérifié en direct. Résultat : parcourir la source 750g sans filtre
ne remontait jamais aucune recette.

Corrigé en lisant un endpoint différent quand query est vide/absent :
dernieres-recettes.htm, le vrai catalogue paginé "dernières recettes" de
750g.com (pagination réelle via &page=N, contrairement à l'endpoint de
recherche). nextCursor suit désormais cette même distinction : toujours
null pour une recherche par texte (l'endpoint ne pagine pas), calculé
normalement pour le parcours sans filtre (une page sans aucune carte en
est le signal de fin, cet endpoint ne renvoyant ni 404 ni redirection une
fois la dernière page dépassée).

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

* feat(recipes): scroll infini + placeholders sur le parcours des sources externes

Remplace le bouton "Voir plus" de RecipeSourcesPanel par un scroll
infini : une ligne sentinelle en fin de liste (SourceItemTable), observée
via IntersectionObserver scopé au conteneur scrollable de la table,
déclenche le chargement de la page suivante quand elle approche du bas.

Le panel précharge en plus la page suivante dès que la page courante
s'affiche (pas seulement au moment où la sentinelle devient visible), pour
qu'un défilement rapide tombe le plus souvent sur une réponse déjà
arrivée plutôt que de déclencher un aller-retour réseau à ce moment précis.

Pendant un chargement (préchargé ou non), SourceItemTable ajoute des
lignes squelettes qui pulsent en bas de la liste au lieu de laisser un
vide. Un échec de chargement n'efface plus la liste déjà chargée comme
avant (bug corrigé au passage) — un message avec un lien "Réessayer"
s'affiche à la place ; ce correctif inclut aussi le nettoyage d'un
préchargement en échec qui, sinon, aurait fait rejouer indéfiniment la
même promesse déjà rejetée à chaque tentative de réessai.

Deux nouveaux scénarios Cypress (recipe-sources.feature) : chargement
automatique de pages supplémentaires sans bouton, et réessai après un
échec du chargement suivant. Suite e2e complète relancée (78/79, le seul
échec restant est un test préexistant sans rapport, recipe-form.feature,
signalé séparément).

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:09:23 +02:00
kyuno053
88666f0ac5
fix(recipes): corrige la liste vide de sevenFiftyGAdapter quand le filtre est vide (#68)
L'endpoint IA que list() utilisait pour toute recherche (SEARCH_URL,
/genius/query/) répond avec un corps de réponse vide dès que query est
vide — vérifié en direct. Résultat : parcourir la source 750g sans filtre
ne remontait jamais aucune recette.

Corrigé en lisant un endpoint différent quand query est vide/absent :
dernieres-recettes.htm, le vrai catalogue paginé "dernières recettes" de
750g.com (pagination réelle via &page=N, contrairement à l'endpoint de
recherche). nextCursor suit désormais cette même distinction : toujours
null pour une recherche par texte (l'endpoint ne pagine pas), calculé
normalement pour le parcours sans filtre (une page sans aucune carte en
est le signal de fin, cet endpoint ne renvoyant ni 404 ni redirection une
fois la dernière page dépassée).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:08:28 +02:00
kyuno053
5ea1026151
feat(recipes): adaptateurs RecipeSourceAdapter pour Marmiton, 750g et Manger Bouger (#67)
* feat(recipes): ajoute un adaptateur RecipeSourceAdapter pour Marmiton

Étend jsonLdRecipeAdapter (json-ld-recipe.ts) plutôt que de dupliquer sa
logique : marmitonAdapter délègue fetchDetail/parse directement à
l'adaptateur générique JSON-LD (une page recette marmiton.org expose un
Recipe schema.org standard), et n'ajoute que ce que l'adaptateur
générique ne peut pas offrir — un list() qui lit l'ItemList schema.org
embarqué sur la page de résultats de recherche de marmiton.org (pagination
via &page=N, fin de résultats détectée via la réponse 404 renvoyée
au-delà de la dernière page).

extractJsonLdBlocks est exporté depuis json-ld-recipe.ts pour être
réutilisé par marmiton.ts sans dupliquer le regex d'extraction des blocs
<script type="application/ld+json">.

Enregistre marmitonAdapter dans registerAllRecipeSources (sources/index.ts)
— contrairement à jsonLdRecipeAdapter lui-même, c'est un adaptateur concret
par site, donc une Source household-toggleable légitime.

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

* feat(recipes): ajoute un adaptateur RecipeSourceAdapter pour 750g

Suit le même schéma que marmitonAdapter (construit sur jsonLdRecipeAdapter),
avec deux différences propres à 750g.com :

- list() n'a pas d'ItemList JSON-LD à lire sur ses résultats de recherche
  (la recherche du site est un widget client-side) — appelle donc
  directement le endpoint GET que ce widget interroge lui-même en interne
  (un « moteur de réponse IA » qui renvoie un lot de recettes pour une
  requête en texte libre), et scrape les cartes de résultat par regex en
  associant à chaque lien de recette sa dernière image précédente plutôt
  qu'un zip naïf par index (des images décoratives sans carte associée
  existent réellement dans ce fragment). Vérifié en direct : demander une
  « page 2 » revient toujours vide, donc nextCursor vaut toujours null,
  comme theMealDbAdapter.
- parse() ne délègue pas aussi directement à jsonLdRecipeAdapter.parse que
  marmitonAdapter — le générateur JSON-LD de 750g.com a deux bugs réels :
  des caractères de contrôle bruts non échappés dans certaines chaînes JSON
  (~1 recette sur 3 dans un échantillon vérifié en direct, sinon
  JSON.parse échoue et jsonLdRecipeAdapter rapporte à tort « aucun
  Recipe trouvé »), et un texte parfois doublement encodé en entités HTML
  (ex. un vrai « é » devient &amp;eacute; au lieu de &eacute;). Les deux
  sont corrigés en pré/post-traitement autour de la même délégation, pas
  une réimplémentation.

Enregistre sevenFiftyGAdapter dans registerAllRecipeSources
(sources/index.ts), au même titre que marmitonAdapter.

Complète aussi test/sources/sources-index.test.ts, qui ne couvrait encore
que TheMealDB malgré l'ajout de Marmiton dans une PR précédente.

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

* feat(recipes): ajoute un adaptateur RecipeSourceAdapter pour Manger Bouger

Suit le même schéma que marmitonAdapter/sevenFiftyGAdapter (construit sur
jsonLdRecipeAdapter), avec des différences propres à mangerbouger.fr
(« La Fabrique à Menus », Santé publique France) :

- list() n'utilise pas de JSON-LD du tout — la page de résultats (une app
  Next.js) n'embarque aucun ItemList. Elle est cependant rendue
  côté serveur et expose le même state Redux que le client hydrate, via un
  <script id="__NEXT_DATA__">, qui contient déjà tout ce dont list() a
  besoin (slug/nom/image, pagination). Vérifié en direct : ?query=<texte
  libre> filtre bien côté serveur, et hasMorePages donne un signal de fin
  de pagination plus propre que le 404 de Marmiton ou l'absence de vraie
  pagination de 750g.
- parse() délègue à jsonLdRecipeAdapter mais corrige deux lacunes réelles
  et systématiques de son propre JSON-LD (vérifiées sur 9 recettes,
  72 étapes) : recipeInstructions[].text est un document Slate.js
  sérialisé en JSON (pas du texte) plutôt qu'être aplati ; recipeYield est
  absent partout alors que le nombre de portions existe bien côté site
  (__NEXT_DATA__) — les deux sont corrigés par un patch structuré (parse →
  mutation → réécriture) avant délégation, pas une réimplémentation.

Enregistre mangerBougerAdapter dans registerAllRecipeSources
(sources/index.ts) et complète sources-index.test.ts.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 19:01:46 +02:00
kyuno053
92bea914e8
feat(tech-steps): fiabilise la détection des tech steps (corpus + LLM + corrections utilisateur) (#66)
* feat(tech-steps): fiabilise la detection des tech steps (corpus + LLM + corrections utilisateur)

Une seule feature livree en une seule PR, en 5 phases :

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

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

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

* fix(tech-steps): calibre le seuil F1 sur une vraie execution et corrige un bug de comptage

Docker etant redevenu disponible dans cette session, j'ai pu lancer pour de
vrai la suite Mocha d'apps/api (334/334, y compris les tests Phase 1/2
qui n'avaient pu etre executes precedemment) ainsi que les scripts de la
Phase 5 contre une vraie base de test.

- tech-step-eval-dataset.ts : corrige un vrai bug d'auteur - "Take the
  plates..." collisionnait avec le synonyme anglais enregistre "plates"
  (technique plate), invalidant ce cas negatif. Remplace par "dishes".
- tech-step-eval-runner.ts : F1 reel mesure = 0.815 (33 TP / 9 FP / 6 FN).
  Documente ce chiffre et les vraies erreurs de classification decouvertes
  (ex: "Blanchissez les haricots verts..." classifie a tort comme "peel")
  - des faiblesses reelles du classifieur que ce harness est cense
  detecter, pas a masquer en ajustant le jeu de test.
- retrain-tech-steps.ts : le script loggait `appliedIds.length`/
  `rejectedIds.length` (ce qui a ete demande) au lieu du `count` reel
  retourne par `updateMany` (ce qui a vraiment ete modifie) - un id
  inexistant faisait afficher un faux succes. Decouvert en executant le
  script pour de vrai avec des ids partiellement invalides.

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

* fix(tech-steps): corrige un span de correction incorrect sur un highlight existant

Bug reel trouve en lancant l'application pour de vrai et en cliquant sur
un highlight existant : la correction soumise couvrait presque toute la
description au lieu du seul mot-cle cliqué (ex: [6, 56) au lieu de [6, 13)
pour "mijoter").

Cause : StepDescription.tsx capturait `start` dans un `const` par
iteration de `.map()` (correct), mais utilisait `offset` directement (la
variable mutable partagee, pas une valeur capturee) pour `end` dans le
gestionnaire onClick - une fermeture classique sur variable de boucle
encore mutee. Par le temps ou l'utilisateur clique reellement (bien apres
la fin du rendu), `offset` contient sa valeur finale (fin de la
description entiere), pas celle du segment concerne.

Corrige en capturant `end` dans un `const` au meme endroit que `start`.
Renforce aussi l'assertion e2e correspondante (recipes.ts) qui ne
verifiait auparavant que la requete avait ete faite, jamais son contenu -
elle serait passee malgre ce bug.

Verifie en conditions reelles : recette creee via l'UI, correction
soumise, span persiste verifie directement en base (start=6, end=13,
previous=simmer, corrected=grill).

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

* chore: ignore les telechargements Cypress (artefact de run local)

* feat(tech-steps): distingue les corrections manuelles des détections auto

Les corrections utilisateur (via TechStepCorrectionPopover) sont
désormais écrites directement dans StepTechStep, avec une colonne
`source` ("auto" | "manual") qui les distingue des matches du
classifieur NLP :

- Migration `step_tech_step_source` ajoutant `source` (défaut "auto")
- `applyManualCorrection`/`renumberStepTechSteps` dans
  recipe-tech-step-correction.service.ts : une correction met à jour
  ou crée l'entrée StepTechStep concernée (source "manual"), la
  réponse de l'endpoint inclut désormais le techSteps à jour du step
  (SubmitTechStepCorrectionResult), pas seulement l'audit de
  correction
- backfill-tech-steps.ts préserve les entrées "manual" existantes :
  seules les entrées "auto" sont recalculées, et un nouveau match
  auto chevauchant une correction manuelle est ignoré plutôt
  qu'inséré en doublon — vérifié en base réelle (une correction
  manuelle survit intacte à un backfill complet)
- Le front distingue visuellement les deux (StepDescription.tsx,
  recipes.scss : `.step-tech-step--manual`, couleur Turmeric au lieu
  de Basil), avec un tooltip "(correction manuelle)" et un indicateur
  de découvrabilité de la fonctionnalité dans RecipeDetailPanel

Corrige aussi deux bugs trouvés en testant en conditions réelles :
- StepDescription.tsx : le clic sur un highlight existant lisait la
  variable `offset` (mutable, partagée par la boucle) au lieu d'une
  valeur capturée, envoyant un `end` erroné (fin de la description
  entière au lieu du span du mot cliqué)
- backfill-tech-steps.ts : le garde `import.meta.url ===
  file://${process.argv[1]}` ne matche jamais sur Windows (chemins à
  antislash), le script ne faisait donc rien en exécution directe ;
  remplacé par `pathToFileURL(process.argv[1]).href`

335 tests apps/api passants, 40/40 composants Cypress, 75/76 e2e
Cypress (1 flake pré-existant sans rapport, non touché ici).

* fix(worker): corrige le build Docker de tech-step-llm-worker

docker compose build tech-step-llm-worker échouait sur deux problèmes
en cascade, tous deux liés à l'isolation volontaire de ce service hors
du monorepo pnpm (seul son propre package.json/tsconfig.json est copié
dans son contexte de build) :

- pnpm install --ignore-workspace --frozen-lockfile échouait
  (ERR_PNPM_IGNORED_BUILDS) : sans "packageManager" dans son
  package.json, corepack télécharge le pnpm le plus récent
  (11.22.0), qui a durci en erreur bloquante ce qui n'était qu'un
  avertissement sur les builds de dépendances ignorés
  (esbuild/node-llama-cpp). Le reste du repo est épargné parce que
  apps/api/Dockerfile copie le package.json racine, qui pinne déjà
  pnpm@10.12.4 — ce pin ne pouvait pas atteindre ce service isolé.
  Fixé en pinnant la même version ici.
- tsc échouait ensuite (TS5083 puis erreurs en cascade dans les .d.ts
  de node-llama-cpp) : tsconfig.json de ce service extends le
  tsconfig.base.json racine (skipLibCheck notamment), jamais copié
  dans le contexte de build. Fixé en le copiant avant tsconfig.json.

Vérifié : `docker compose build tech-step-llm-worker` complet en local.

* fix(tech-steps): empêche le contexte d'un match d'avaler une correction manuelle voisine

La correction manuelle ne s'affichait pas quand elle portait sur du texte
qui n'était pas une technique à l'origine — reproduit en live : une
description avec un seul match auto-détecté ("mijoter") voit son
contexte de clause s'étendre sur toute la description dès que
splitIntoClauses (tech-step-matcher.ts) n'a trouvé qu'un seul candidat
NER (le cas courant), même quand ce candidat n'a aucun rapport avec le
reste du texte. splitDescriptionByTechSteps avançait alors son curseur
jusqu'à la fin de ce contexte large, ce qui faisait purement et
simplement disparaître (silencieusement, sans erreur) toute correction
manuelle ajoutée plus loin dans la même description — un mot pourtant
sans aucun rapport avec la technique auto-détectée.

Le contexte d'un match est purement cosmétique (StepDescription.tsx le
rend identique à du texte brut depuis que sa mise en valeur dédiée a
été désactivée) et ne doit donc jamais coûter son propre highlight à
un *autre* match. splitDescriptionByTechSteps distingue maintenant
deux notions : le chevauchement entre les spans *keyword* stricts de
deux entrées (toujours un vrai conflit, l'entrée la plus tardive est
toujours ignorée, comportement inchangé) et le chevauchement du
contexte *cosmétique* d'une entrée sur le keyword d'une autre (jamais
un vrai conflit désormais : le contexte est simplement rogné pour
laisser la place, plutôt que l'entrée voisine entière étant abandonnée).

Vérifié en conditions réelles (Docker) : une correction manuelle sur
"materiel" dans "Faire mijoter la sauce, puis ranger le materiel."
s'affiche maintenant correctement à côté du highlight auto "mijoter",
et survit à un rechargement complet de la page.

Nouveau test de régression dans highlight-tech-steps.cy.tsx
reproduisant exactement ce cas ; les 18 tests du fichier (dont tous
les cas de contexte/malformation déjà couverts) passent toujours.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 15:57:57 +02:00
156 changed files with 20131 additions and 2576 deletions

View file

@ -21,3 +21,23 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# over plain HTTP a Secure cookie is silently never sent back by the # over plain HTTP a Secure cookie is silently never sent back by the
# browser, so login "succeeds" but every subsequent request 401s. # browser, so login "succeeds" but every subsequent request 401s.
# COOKIE_SECURE=false # COOKIE_SECURE=false
# Required — secret shared between "app" and "tech-step-intent-service"
# (docker-compose.yml, apps/api/src/config/env.ts). Unlike
# INTERNAL_WORKER_SECRET below, there's no "leave it unset" escape hatch:
# tech-step-intent-service is a core dependency, not an optional background
# job — without it, no recipe step can have its techniques detected at all.
# Generate your own the same way as JWT_SECRET above.
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Only needed to run the optional `tech-step-llm-worker` service — shared
# between it and "app" (docker-compose.yml). Generate your own the same
# way as JWT_SECRET above; leave both this and the service commented
# out/unset to run without it.
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optional — cron expression (node-cron syntax) the worker wakes up on to
# run its audit/feedback-loop jobs. Default: weekly, Sunday 03:00 — a
# provisional floor, not a calibrated value (see
# services/tech-step-llm-worker/README.md).
# TECH_STEP_WORKER_CRON=0 3 * * 0

View file

@ -12,22 +12,32 @@ on:
push: push:
env: env:
DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public" DATABASE_URL: "postgresql://ci:ci@postgres:5432/batchcooking_ci?schema=public"
# Test-only secret, never used outside CI — real deployments must set their own. # Test-only secret, never used outside CI — real deployments must set their own.
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+" JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
# Same reasoning as JWT_SECRET above — lets tech-step-worker.routes.test.ts
# exercise the success path (matching secret), not just the "unset"
# rejection every environment that doesn't set this gets by default.
INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+"
# Shared between the `test` job's own uvicorn step (below) and apps/api's
# IntentServiceClient — see the `test` job for why this can't be a
# `services:` container like postgres above (GitHub Actions can only pull
# a published image, not build services/tech-step-intent-service/Dockerfile).
INTENT_SERVICE_BASE_URL: "http://localhost:8000"
INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+"
jobs: jobs:
# Four independent jobs, no needs: between them — each starts in parallel # Five independent jobs, no needs: between them — each starts in parallel
# and reports as its own check, instead of the previous single chained # and reports as its own check, instead of the previous single chained
# "lint-and-test then e2e" pipeline. # "lint-and-test then e2e" pipeline.
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: https://github.com/pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: https://github.com/actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
@ -45,34 +55,80 @@ jobs:
POSTGRES_PASSWORD: ci POSTGRES_PASSWORD: ci
POSTGRES_DB: batchcooking_ci POSTGRES_DB: batchcooking_ci
ports: ports:
- 5432:5432 - 5433:5432
options: >- options: >-
--health-cmd pg_isready --health-cmd pg_isready
--health-interval 5s --health-interval 5s
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
steps: steps:
- uses: actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: https://github.com/pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: https://github.com/actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
- uses: https://github.com/astral-sh/setup-uv@v5
with:
python-version: "3.12"
enable-cache: true
# `services:` (like the `postgres` container above) can only pull an
# already-published image — it can't build
# services/tech-step-intent-service/Dockerfile from this checkout.
# Running `uvicorn` as a plain background step instead: it keeps
# running for the rest of this job (GitHub Actions steps in one job
# share the same runner process tree), and `pnpm --filter api test`
# below needs a real instance to talk to per this repo's "never mock
# an internal service" test convention — same reasoning as the real
# `postgres` container just above, not a mock HTTP server.
- name: Install services/tech-step-intent-service
working-directory: services/tech-step-intent-service
run: uv sync --frozen
- name: Start services/tech-step-intent-service in the background
working-directory: services/tech-step-intent-service
run: |
uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 &
# `/health` only returns 200 once this service has finished
# training itself from scratch (no model ever persisted to disk —
# see its own README) — measured at ~540s (fr) / ~390s (en),
# ~930s combined, against the current ~74-technique corpus (see
# docker-compose.yml's healthcheck for the same reasoning and why
# this grew slightly from the original ~670s).
timeout 1200 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done'
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm --filter api exec prisma migrate deploy - run: pnpm --filter api exec prisma migrate deploy
- run: pnpm --filter api test - run: pnpm --filter api test
intent-service-test:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/astral-sh/setup-uv@v5
with:
python-version: "3.12"
enable-cache: true
- name: Install services/tech-step-intent-service
working-directory: services/tech-step-intent-service
run: uv sync --frozen
- name: Run pytest
working-directory: services/tech-step-intent-service
run: uv run pytest -q
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: https://github.com/pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: https://github.com/actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
@ -83,17 +139,17 @@ jobs:
e2e: e2e:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: https://github.com/pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: https://github.com/actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
- name: Cache Cypress binary - name: Cache Cypress binary
uses: actions/cache@v4 uses: https://github.com/actions/cache@v4
with: with:
path: ~/.cache/Cypress path: ~/.cache/Cypress
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
@ -103,7 +159,10 @@ jobs:
# download (see apps/web's cypress caveat in the README) — install it # download (see apps/web's cypress caveat in the README) — install it
# explicitly so `cypress run` finds it. # explicitly so `cypress run` finds it.
- run: pnpm --filter web exec cypress install - run: pnpm --filter web exec cypress install
- run: pnpm --filter web e2e - name: Run E2E tests
env:
HOST: "0.0.0.0"
run: pnpm --filter web e2e
# No dev server needed here — Cypress spins up its own Vite dev # No dev server needed here — Cypress spins up its own Vite dev
# server internally for component testing (see cypress.config.ts's # server internally for component testing (see cypress.config.ts's
# `component.devServer`), unlike `e2e` above which needs the real app # `component.devServer`), unlike `e2e` above which needs the real app

16
.gitignore vendored
View file

@ -71,11 +71,12 @@ web_modules/
!.env.example !.env.example
!.env.test.example !.env.test.example
# node-nlp's default auto-save file (apps/api/src/lib/recipe-matching/ # Python virtualenvs/caches for services/tech-step-intent-service (this repo
# tech-step-matcher.ts explicitly disables autoSave/autoLoad, but this is a # is otherwise all-Node — see that service's own .gitignore for the rest;
# belt-and-suspenders guard against it ever reappearing — a stale trained # duplicated here too since some tooling only honors the repo-root file).
# model on disk must never silently shadow TECH_STEP_TRAINING_DATA). services/tech-step-intent-service/.venv/
model.nlp services/tech-step-intent-service/__pycache__/
services/tech-step-intent-service/.pytest_cache/
# parcel-bundler cache (https://parceljs.org/) # parcel-bundler cache (https://parceljs.org/)
.cache .cache
@ -157,3 +158,8 @@ tmp-mockups/
# IA # IA
.claude/ .claude/
# Cypress run artifacts — regenerated locally/in CI, never meant to be committed
apps/web/cypress/screenshots/
apps/web/cypress/videos/
apps/web/cypress/downloads/

View file

@ -44,6 +44,8 @@ runtime Node pur (Docker, pas de transpilation à la volée), voir la note dans
- Node.js 22 (voir `.nvmrc`) - Node.js 22 (voir `.nvmrc`)
- pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle) - pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle)
- Docker (pour Postgres en local) - Docker (pour Postgres en local)
- Python 3.12+ et [`uv`](https://docs.astral.sh/uv/) (pour
`services/tech-step-intent-service` en dev natif — requis, voir plus bas)
## Installation ## Installation
@ -100,6 +102,17 @@ pnpm --filter api exec prisma migrate dev
# techniques...) — automatique après `prisma migrate reset`, sinon à la main : # techniques...) — automatique après `prisma migrate reset`, sinon à la main :
pnpm --filter api prisma:seed pnpm --filter api prisma:seed
# Microservice de détection des techniques (spaCy) — requis, `pnpm dev:api`
# ne peut plus détecter aucune technique de cuisine sans lui. Lance-le en
# premier et laisse-le tourner : il s'entraîne lui-même à chaque démarrage
# (~11 minutes pour le corpus actuel, voir son propre README) avant de
# répondre quoi que ce soit sur /health.
cd services/tech-step-intent-service
uv sync
cp .env.example .env # édite-le : même INTENT_SERVICE_SECRET que apps/api/.env
uv run uvicorn intent_service.main:app --reload --port 8000
cd ../..
# Backend (http://localhost:3000) # Backend (http://localhost:3000)
pnpm dev:api pnpm dev:api
@ -137,7 +150,7 @@ pnpm --filter web cy:run:component # tests de composant UI isolés (Cypress com
pnpm build # build de tous les workspaces pnpm build # build de tous les workspaces
``` ```
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute quatre jobs indépendants (`lint`, `test`, `build`, `e2e` — ce dernier lance aussi `cy:run:component`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`). La CI GitHub Actions (`.github/workflows/ci.yml`) exécute cinq jobs indépendants (`lint`, `test`, `intent-service-test`, `build`, `e2e` — ce dernier lance aussi `cy:run:component`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. `test` démarre `services/tech-step-intent-service` en arrière-plan (voir ce fichier) puisque la suite Mocha ne mocke jamais un service interne. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
### Base de test isolée de la base de dev (`apps/api`) ### Base de test isolée de la base de dev (`apps/api`)
@ -158,6 +171,13 @@ Un garde-fou (`assertRunningAgainstTestDatabase()`) refuse d'exécuter
`resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la `resetDatabase()` si `DATABASE_URL` ne contient ni `"test"` ni `"ci"` — la
seule base qu'il doit rejeter est ta vraie base de dev. seule base qu'il doit rejeter est ta vraie base de dev.
`services/tech-step-intent-service` doit aussi tourner en local avant
`pnpm --filter api test` — les tests touchant `tech-step-matcher.ts` passent
par le vrai service (jamais un mock, voir
[specs/dev-conventions.md](specs/dev-conventions.md)) et échouent avec une
erreur de connexion, pas une assertion utile, s'il n'est pas démarré. Voir la
section [Développement](#développement) ci-dessus.
## Déploiement ## Déploiement
Une seule image Docker (`apps/api/Dockerfile`) sert à la fois l'API et le frontend Une seule image Docker (`apps/api/Dockerfile`) sert à la fois l'API et le frontend
@ -180,10 +200,12 @@ synchronisation de la table `sources` depuis le registre d'adaptateurs de code
puis `node dist/server.js`. Les trois étapes sont sûres/idempotentes à puis `node dist/server.js`. Les trois étapes sont sûres/idempotentes à
répéter à chaque redémarrage du conteneur. répéter à chaque redémarrage du conteneur.
`docker-compose.yml` ne définit donc que deux services : `postgres` et `app` (un Le duo `postgres`/`app` de `docker-compose.yml` n'expose donc qu'un seul port
seul port, `APP_PORT`, défaut `3000` — plus de `WEB_PORT`/`CORS_ORIGIN` à applicatif, `APP_PORT` (défaut `3000`) — plus de `WEB_PORT`/`CORS_ORIGIN` à
coordonner entre deux origines, le frontend et l'API sont désormais servis depuis coordonner entre deux origines, le frontend et l'API sont désormais servis
la même origine). depuis la même origine. Les deux autres services du fichier
(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
au host — voir leurs propres README pour leur rôle.
**Pas de registre d'image** dans cette configuration : l'instance **Portainer** de **Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
production est reliée directement au dépôt Git et reconstruit elle-même production est reliée directement au dépôt Git et reconstruit elle-même

View file

@ -12,3 +12,16 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# JWT_EXPIRES_IN=7d # JWT_EXPIRES_IN=7d
# AUTH_COOKIE_NAME=session # AUTH_COOKIE_NAME=session
# CORS_ORIGIN=http://localhost:5173 # CORS_ORIGIN=http://localhost:5173
# INTENT_SERVICE_BASE_URL=http://localhost:8000
# Required — services/tech-step-intent-service must be running locally (see
# that service's own README) for any recipe save/preview to detect
# techniques at all. Must match that service's own INTENT_SERVICE_SECRET.
# Generate your own the same way as JWT_SECRET above.
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Only needed if you're running services/tech-step-llm-worker locally —
# every /internal/tech-steps/* request is rejected outright while unset.
# Generate your own the same way as JWT_SECRET above; must match the
# worker's own INTERNAL_WORKER_SECRET.
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars

View file

@ -13,3 +13,17 @@ DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking_test?sc
# Required, no default on purpose — generate your own, e.g.: # Required, no default on purpose — generate your own, e.g.:
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" # node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Required — the Mocha suite exercises the real techStepClassifier, which
# now round-trips over HTTP to services/tech-step-intent-service (no mocks
# of internal services, per this repo's test conventions). Start that
# service locally first (see its own README) with a matching
# INTENT_SERVICE_SECRET, or every test touching tech-step-matcher.ts fails
# with a connection error rather than a useful assertion failure.
INTENT_SERVICE_BASE_URL=http://localhost:8000
INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optional — only needed to exercise tech-step-worker.routes.test.ts's
# success path (a request with a matching secret); every other test runs
# fine without it. Any value at least 32 chars works locally.
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars

View file

@ -2,5 +2,6 @@
"extension": ["ts"], "extension": ["ts"],
"spec": "test/**/*.test.ts", "spec": "test/**/*.test.ts",
"node-option": ["import=tsx"], "node-option": ["import=tsx"],
"timeout": 10000 "timeout": 10000,
"require": ["test-support/mocha-root-hooks.ts"]
} }

View file

@ -26,7 +26,6 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.1", "express": "^4.21.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"node-nlp": "4.27.0",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },

View file

@ -0,0 +1,47 @@
-- CreateTable
CREATE TABLE "step_tech_step_correction" (
"id" SERIAL NOT NULL,
"step_id" INTEGER NOT NULL,
"corrector_id" INTEGER NOT NULL,
"start" INTEGER NOT NULL,
"end" INTEGER NOT NULL,
"previous_tech_step_id" INTEGER,
"corrected_tech_step_id" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"consumed_at" TIMESTAMP(3),
CONSTRAINT "step_tech_step_correction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tech_step_training_suggestion" (
"id" SERIAL NOT NULL,
"tech_step_id" INTEGER NOT NULL,
"locale" TEXT NOT NULL,
"suggested_synonyms" TEXT[],
"suggested_utterances" TEXT[],
"source_type" TEXT NOT NULL,
"source_correction_id" INTEGER,
"status" TEXT NOT NULL DEFAULT 'pending',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tech_step_training_suggestion_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_step_id_fkey" FOREIGN KEY ("step_id") REFERENCES "step"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_corrector_id_fkey" FOREIGN KEY ("corrector_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_previous_tech_step_id_fkey" FOREIGN KEY ("previous_tech_step_id") REFERENCES "tech_step"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_correction" ADD CONSTRAINT "step_tech_step_correction_corrected_tech_step_id_fkey" FOREIGN KEY ("corrected_tech_step_id") REFERENCES "tech_step"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tech_step_training_suggestion" ADD CONSTRAINT "tech_step_training_suggestion_tech_step_id_fkey" FOREIGN KEY ("tech_step_id") REFERENCES "tech_step"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tech_step_training_suggestion" ADD CONSTRAINT "tech_step_training_suggestion_source_correction_id_fkey" FOREIGN KEY ("source_correction_id") REFERENCES "step_tech_step_correction"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "step_tech_step" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'auto';

View file

@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "utensil" (
"id" SERIAL NOT NULL,
"key" TEXT NOT NULL,
CONSTRAINT "utensil_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "utensil_key_key" ON "utensil"("key");

View file

@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "step_tech_step_ingredient" (
"id" SERIAL NOT NULL,
"step_id" INTEGER NOT NULL,
"tech_step_order" INTEGER NOT NULL,
"ingredient_id" INTEGER NOT NULL,
"quantity" DECIMAL(10,2),
"unit_id" INTEGER,
"start" INTEGER NOT NULL,
"end" INTEGER NOT NULL,
"source" TEXT NOT NULL DEFAULT 'auto',
CONSTRAINT "step_tech_step_ingredient_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "step_tech_step_utensil" (
"id" SERIAL NOT NULL,
"step_id" INTEGER NOT NULL,
"tech_step_order" INTEGER NOT NULL,
"utensil_id" INTEGER NOT NULL,
"start" INTEGER NOT NULL,
"end" INTEGER NOT NULL,
"source" TEXT NOT NULL DEFAULT 'auto',
CONSTRAINT "step_tech_step_utensil_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_step_id_tech_step_order_fkey" FOREIGN KEY ("step_id", "tech_step_order") REFERENCES "step_tech_step"("step_id", "order") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_ingredient" ADD CONSTRAINT "step_tech_step_ingredient_unit_id_fkey" FOREIGN KEY ("unit_id") REFERENCES "unit"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_utensil" ADD CONSTRAINT "step_tech_step_utensil_step_id_tech_step_order_fkey" FOREIGN KEY ("step_id", "tech_step_order") REFERENCES "step_tech_step"("step_id", "order") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "step_tech_step_utensil" ADD CONSTRAINT "step_tech_step_utensil_utensil_id_fkey" FOREIGN KEY ("utensil_id") REFERENCES "utensil"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -121,6 +121,10 @@ model UserProfile {
/// list regardless of that real-world cardinality. /// list regardless of that real-world cardinality.
administeredHouses House[] @relation("HouseAdmin") administeredHouses House[] @relation("HouseAdmin")
preferences UserPreference? 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") @@map("user_profiles")
} }
@ -517,6 +521,9 @@ model Ingredient {
dislikedBy UserProfileDislikedIngredient[] dislikedBy UserProfileDislikedIngredient[]
/// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}. /// Diet regimes this ingredient is compatible with — see {@link IngredientDiet}.
diets IngredientDiet[] diets IngredientDiet[]
/// Mentions of this ingredient detected in a step's free text alongside a
/// technique — see `StepTechStepIngredient`.
stepTechSteps StepTechStepIngredient[]
@@map("ingredients") @@map("ingredients")
} }
@ -593,6 +600,12 @@ model Unit {
toBaseFactor Decimal @default(1) @map("to_base_factor") @db.Decimal(12, 4) toBaseFactor Decimal @default(1) @map("to_base_factor") @db.Decimal(12, 4)
recipeIngredients RecipeIngredient[] recipeIngredients RecipeIngredient[]
/// Ingredient mentions detected alongside a technique in a step's free
/// text (e.g. "50g" resolved against this `Unit`) — see
/// `StepTechStepIngredient`. Distinct from `recipeIngredients` above
/// (the recipe's structured ingredient list): a step can mention a
/// quantity+unit that was never itself an ingredient list line.
stepTechStepIngredients StepTechStepIngredient[]
@@map("unit") @@map("unit")
} }
@ -623,22 +636,50 @@ model RecipeIngredient {
/// Matching a step's free text against these (`tech-step-matcher.ts`'s /// Matching a step's free text against these (`tech-step-matcher.ts`'s
/// `TechStepClassifierService`) used to go through a DB-backed /// `TechStepClassifierService`) used to go through a DB-backed
/// `TechStepMapping` table of per-locale regex expressions — replaced with /// `TechStepMapping` table of per-locale regex expressions — replaced with
/// a node-nlp model trained from in-code data /// a spaCy-based model (`services/tech-step-intent-service`) trained from
/// (`tech-step-training-data.ts`) once regexes turned out unable to /// in-code data (`tech-step-training-data.ts`) once regexes turned out
/// generalize past their own literal vocabulary. Nothing queries/edits /// unable to generalize past their own literal vocabulary. Nothing
/// that matching data at runtime anymore (it only ever feeds the /// queries/edits that matching data at runtime anymore (it only ever feeds
/// classifier's one-time training pass), so it no longer needs a table of /// that service's one-time training pass), so it no longer needs a table
/// its own — this row now only exists to be a stable id/key other tables /// of its own — this row now only exists to be a stable id/key other
/// (`StepTechStep`) reference. /// tables (`StepTechStep`) reference.
model TechStep { model TechStep {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
key String @unique key String @unique
steps StepTechStep[] 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") @@map("tech_step")
} }
/// `key` is `@unique`, same bare id+key shape as `TechStep` — no
/// categorization taxonomy like `Ingredient` needed yet, and no matching
/// data of its own here either: unlike `TechStep` (whose matching synonyms
/// used to live in TS and were moved into
/// `services/tech-step-intent-service`'s `training_data.py`), this catalog
/// was *born* owned by that service (`utensil_vocabulary.py`) since nothing
/// pre-existing needed it — this row only exists to be a stable id/key
/// `StepTechStepUtensil` references, and to carry a French label
/// (`apps/web`'s `catalog.utensils.<key>`, see `reference-seed-data.ts`'s
/// `UTENSILS`).
model Utensil {
id Int @id @default(autoincrement())
key String @unique
steps StepTechStepUtensil[]
@@map("utensil")
}
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the /// 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 /// 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 /// single recipe, which isn't reconcilable with steps being shared across
@ -652,6 +693,9 @@ model Step {
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
techSteps StepTechStep[] techSteps StepTechStep[]
/// User-submitted corrections to this step's detected techniques — see
/// `StepTechStepCorrection`.
corrections StepTechStepCorrection[]
@@map("step") @@map("step")
} }
@ -682,6 +726,18 @@ model Step {
/// techniques from scratch (`recipe.service.ts`'s `updateRecipe` deletes /// techniques from scratch (`recipe.service.ts`'s `updateRecipe` deletes
/// and recreates every `Step`/`StepTechStep`, never a partial patch) — /// and recreates every `Step`/`StepTechStep`, never a partial patch) —
/// graceful degradation, not a permanent gap. /// 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 { model StepTechStep {
stepId Int @map("step_id") stepId Int @map("step_id")
techStepId Int @map("tech_step_id") techStepId Int @map("tech_step_id")
@ -690,10 +746,154 @@ model StepTechStep {
end Int? end Int?
contextStart Int? @map("context_start") contextStart Int? @map("context_start")
contextEnd Int? @map("context_end") contextEnd Int? @map("context_end")
source String @default("auto")
step Step @relation(fields: [stepId], references: [id], onDelete: Cascade) step Step @relation(fields: [stepId], references: [id], onDelete: Cascade)
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade) techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
/// Ingredients mentioned in the same clause as this technique occurrence
/// — see `StepTechStepIngredient`.
ingredients StepTechStepIngredient[]
/// Utensils mentioned in the same clause as this technique occurrence —
/// see `StepTechStepUtensil`.
utensils StepTechStepUtensil[]
@@id([stepId, order]) @@id([stepId, order])
@@map("step_tech_step") @@map("step_tech_step")
} }
/// An ingredient mention found in the same *clause* as one `StepTechStep`
/// occurrence (`tech-step-matcher.ts`'s `matchTechStepSpans` — clauses are
/// already the unit a technique is judged on, see that file's doc comment,
/// so "same clause" is the association rule, no dependency-parsing needed).
/// `quantity`/`unitId` are best-effort, populated only when a leading
/// numeric expression immediately preceding the ingredient mention resolved
/// against the `Unit` catalog (`ingredient-matcher.ts`'s
/// `findIngredientMentions`) — both `null` when the clause names the
/// ingredient with no quantity ("ajouter le sel"). `start`/`end` are the
/// ingredient mention's own span in `Step.description`, same `[start, end)`
/// convention as `StepTechStep.start`/`end`. `source` mirrors
/// `StepTechStep.source` (`"auto"` today, room for a future user
/// correction without a shape change).
model StepTechStepIngredient {
id Int @id @default(autoincrement())
stepId Int @map("step_id")
techStepOrder Int @map("tech_step_order")
ingredientId Int @map("ingredient_id")
quantity Decimal? @db.Decimal(10, 2)
unitId Int? @map("unit_id")
start Int
end Int
source String @default("auto")
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
unit Unit? @relation(fields: [unitId], references: [id])
@@map("step_tech_step_ingredient")
}
/// A utensil mention found in the same clause as one `StepTechStep`
/// occurrence — same association rule as `StepTechStepIngredient` (see its
/// doc comment). Detected by
/// `services/tech-step-intent-service`'s own utensil `PhraseMatcher`
/// (`intent_service/utensil_vocabulary.py`), returned alongside technique
/// entities in `POST /v1/process` and filtered to this clause's span by
/// `tech-step-matcher.ts`.
model StepTechStepUtensil {
id Int @id @default(autoincrement())
stepId Int @map("step_id")
techStepOrder Int @map("tech_step_order")
utensilId Int @map("utensil_id")
start Int
end Int
source String @default("auto")
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
utensil Utensil @relation(fields: [utensilId], references: [id], onDelete: Cascade)
@@map("step_tech_step_utensil")
}
/// 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")
}

View file

@ -7,11 +7,13 @@ import { errorLogger } from "./middlewares/error-logger.js";
import { requestLogger } from "./middlewares/request-logger.js"; import { requestLogger } from "./middlewares/request-logger.js";
import { authRouter } from "./modules/auth/auth.routes.js"; import { authRouter } from "./modules/auth/auth.routes.js";
import { houseRouter } from "./modules/house/house.routes.js"; import { houseRouter } from "./modules/house/house.routes.js";
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
import { planningRouter } from "./modules/planning/planning.routes.js"; import { planningRouter } from "./modules/planning/planning.routes.js";
import { preferencesRouter } from "./modules/preferences/preferences.routes.js"; import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
import { profileRouter } from "./modules/profile/profile.routes.js"; import { profileRouter } from "./modules/profile/profile.routes.js";
import { recipeRouter } from "./modules/recipe/recipe.routes.js"; import { recipeRouter } from "./modules/recipe/recipe.routes.js";
import { referenceRouter } from "./modules/reference/reference.routes.js"; import { referenceRouter } from "./modules/reference/reference.routes.js";
import { shoppingListRouter } from "./modules/shopping-list/shopping-list.routes.js";
import { sourcesRouter } from "./modules/sources/sources.routes.js"; import { sourcesRouter } from "./modules/sources/sources.routes.js";
/** /**
@ -38,11 +40,18 @@ export function createServer(): ExpressServer {
server.mountRouter("/auth", authRouter); server.mountRouter("/auth", authRouter);
server.mountRouter("/house", houseRouter); server.mountRouter("/house", houseRouter);
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
// `requireInternalWorker` on every route within (see that router's own
// doc comment), never `requireAuth`. Mounted alongside the other routers
// rather than nested under one of them since it isn't scoped to a single
// recipe/step the way `recipeRouter`'s own correction routes are.
server.mountRouter("/internal/tech-steps", techStepWorkerRouter);
server.mountRouter("/planning", planningRouter); server.mountRouter("/planning", planningRouter);
server.mountRouter("/preferences", preferencesRouter); server.mountRouter("/preferences", preferencesRouter);
server.mountRouter("/profile", profileRouter); server.mountRouter("/profile", profileRouter);
server.mountRouter("/recipes", recipeRouter); server.mountRouter("/recipes", recipeRouter);
server.mountRouter("/reference", referenceRouter); server.mountRouter("/reference", referenceRouter);
server.mountRouter("/shopping-list", shoppingListRouter);
server.mountRouter("/sources", sourcesRouter); server.mountRouter("/sources", sourcesRouter);
// Serves the built frontend (production Docker image only — see // Serves the built frontend (production Docker image only — see

View file

@ -60,6 +60,36 @@ const envSchema = z.object({
.string() .string()
.optional() .optional()
.transform((value) => (value === undefined || value === "" ? undefined : value === "true")), .transform((value) => (value === undefined || value === "" ? undefined : value === "true")),
/**
* Shared secret `services/tech-step-llm-worker` sends as an
* `X-Internal-Worker-Secret` header on every call to `/internal/tech-steps/*`
* (`requireInternalWorker`, `middlewares/require-internal-worker.ts`).
* Optional with no default in the schema itself (unlike `JWT_SECRET`) so
* an environment that doesn't run the worker at all (e.g. this repo's
* existing test suite) never needs to set it but `requireInternalWorker`
* itself rejects every request outright when it's unset, so the surface
* fails closed rather than open if a real deployment forgets to set it.
*/
INTERNAL_WORKER_SECRET: z.string().min(32).optional(),
/**
* Base URL of `services/tech-step-intent-service` (the spaCy-based
* microservice `TechStepClassifierService` delegates NER + intent
* classification to, see `lib/recipe-matching/intent-service-client.ts`).
* Has a default (unlike `DATABASE_URL`/secrets below) since it isn't
* secret and dev natively runs it on a fixed local port Docker Compose
* overrides it to the compose network's service name.
*/
INTENT_SERVICE_BASE_URL: z.string().url().default("http://localhost:8000"),
/**
* Shared secret sent as an `X-Intent-Service-Secret` header on every call
* to `services/tech-step-intent-service`. Unlike `INTERNAL_WORKER_SECRET`
* above, **required, no `.optional()`** that service is a core
* dependency (recipe save/preview can no longer detect any technique
* without it), not an optional background job; an environment that
* forgets to set this must fail loudly at startup, not silently run with
* every technique detection request failing one at a time.
*/
INTENT_SERVICE_SECRET: z.string().min(32, "INTENT_SERVICE_SECRET must be at least 32 characters"),
}); });
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */ /** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */

View file

@ -62,11 +62,12 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }>
// //
// Just a flat list of stable ids here — the actual matching data (per- // Just a flat list of stable ids here — the actual matching data (per-
// locale synonym lists + example phrasings the classifier trains on) lives // locale synonym lists + example phrasings the classifier trains on) lives
// in `lib/recipe-matching/tech-step-training-data.ts`'s // in `services/tech-step-intent-service/intent_service/training_data.py`'s
// `TECH_STEP_TRAINING_DATA`, not here: unlike this list, it's read by // `TECH_STEP_TRAINING_DATA`, not here: it's owned and trained entirely by
// `TechStepClassifierService`'s training pass, not the seed script, so it // that separate Python service (see its own README), not read by this
// doesn't belong alongside the rest of this file's DB-seeded reference // seed script at all, so it doesn't belong alongside the rest of this
// data. Every entry here must have a matching entry there. // file's DB-seeded reference data. Every entry here must have a matching
// entry there.
export const TECH_STEPS: string[] = [ export const TECH_STEPS: string[] = [
"cook", "cook",
"fry", "fry",
@ -94,6 +95,100 @@ export const TECH_STEPS: string[] = [
"bake", "bake",
"plate", "plate",
"coat", "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 // The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
@ -1199,6 +1294,12 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
await prisma.techStep.upsert({ where: { key }, update: {}, create: { key } }); 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 // `Allergy` itself carries no `key` — it's the selectable instance of a
// keyed `Category` (see schema.prisma) — so seeding an allergen means one // keyed `Category` (see schema.prisma) — so seeding an allergen means one
// Category (upserted by key) plus exactly one Allergy row under it, // Category (upserted by key) plus exactly one Allergy row under it,

View file

@ -1,23 +1,26 @@
import { import {
INGREDIENT_LABEL_SYNONYMS_EN, INGREDIENT_LABEL_SYNONYMS_EN,
INGREDIENT_LABEL_SYNONYMS_FR,
INGREDIENT_LABELS_EN, INGREDIENT_LABELS_EN,
INGREDIENT_LABELS_FR,
UNIT_LABELS_EN, UNIT_LABELS_EN,
UNIT_LABELS_FR,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { normalizeText } from "./tech-step-matcher.js"; import { normalizeText } from "./tech-step-matcher.js";
/** /**
* Resolves the free-text `name`/`unit`/`quantity` a `RecipeSourceAdapter` * Resolves the free-text `name`/`unit`/`quantity` a `RecipeSourceAdapter`
* lifts from an English-language source (`ParsedRecipeIngredient`) against * lifts from a recipe source (`ParsedRecipeIngredient`) against our own
* our own `Ingredient`/`Unit` reference catalogs the ingredient-side * `Ingredient`/`Unit` reference catalogs the ingredient-side counterpart
* counterpart to `tech-step-matcher.ts`'s technique detection, built for * to `tech-step-matcher.ts`'s technique detection, built for the same
* the same reason: an English source's raw text has no idea our catalogs * reason: a source's raw text has no idea our catalogs even exist.
* even exist.
* *
* Unlike tech steps (regex mappings hand-authored per technique), * Unlike tech steps (regex mappings hand-authored per technique),
* ingredient/unit labels are plain hand-written English text * ingredient/unit labels are plain hand-written text, one table per
* (`INGREDIENT_LABELS_EN`/`UNIT_LABELS_EN`, `packages/shared`) matching * supported `locale` (`INGREDIENT_LABELS_EN`/`INGREDIENT_LABELS_FR`/
* them against arbitrary free text (extra adjectives, plurals, "large diced * `UNIT_LABELS_EN`/`UNIT_LABELS_FR`, `packages/shared`) matching them
* against arbitrary free text (extra adjectives, plurals, "large diced
* yellow onion" for a catalog entry that's just "Onion") needs its own, * yellow onion" for a catalog entry that's just "Onion") needs its own,
* lighter algorithm: word-tokenize both sides, naively stem for plurals, * lighter algorithm: word-tokenize both sides, naively stem for plurals,
* then look for the catalog phrase's tokens as a contiguous run inside the * then look for the catalog phrase's tokens as a contiguous run inside the
@ -36,17 +39,17 @@ import { normalizeText } from "./tech-step-matcher.js";
* one-time training pass. * one-time training pass.
*/ */
/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its English matching label. */ /** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its matching label in whichever locale it was loaded for. */
export interface IngredientMatchEntry { export interface IngredientMatchEntry {
ingredientId: number; ingredientId: number;
/** English label from `INGREDIENT_LABELS_EN`, e.g. `"Chicken breast"` — matched against free text, never displayed. */ /** Matching label — English (`INGREDIENT_LABELS_EN`) or French (`INGREDIENT_LABELS_FR`) depending on which locale {@link loadIngredientCatalog} was called with, e.g. `"Chicken breast"`/`"Blanc de poulet"` — matched against free text, never displayed. */
label: string; label: string;
} }
/** One `Unit` row trimmed to what {@link matchUnit} needs, alongside its accepted English spellings. */ /** One `Unit` row trimmed to what {@link matchUnit} needs, alongside its accepted spellings in whichever locale it was loaded for. */
export interface UnitMatchEntry { export interface UnitMatchEntry {
unitId: number; unitId: number;
/** Accepted spellings from `UNIT_LABELS_EN`, e.g. `["tbsp", "tbs", "tablespoon", "tablespoons"]`. */ /** Accepted spellings — English (`UNIT_LABELS_EN`) or French (`UNIT_LABELS_FR`), e.g. `["tbsp", "tbs", "tablespoon", "tablespoons"]` or `["cuillère à soupe", "cas", ...]`. Unlike English, a French entry can be genuinely multi-word — see `matchUnit`'s own doc comment. */
synonyms: string[]; synonyms: string[];
} }
@ -58,19 +61,45 @@ export interface UnitMatchEntry {
* of every comparison go through it, not linguistically correct on its * of every comparison go through it, not linguistically correct on its
* own see the module doc comment. * own see the module doc comment.
*/ */
function stemWord(word: string): string { function stemWordEn(word: string): string {
if (word.endsWith("ies") && word.length > 4) return `${word.slice(0, -3)}y`; if (word.endsWith("ies") && word.length > 4) return `${word.slice(0, -3)}y`;
if (word.endsWith("es") && word.length > 3) return word.slice(0, -2); if (word.endsWith("es") && word.length > 3) return word.slice(0, -2);
if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1); if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1);
return word; return word;
} }
/** Lowercases, strips diacritics (via `normalizeText`) and splits `text` into stemmed word tokens — empty/non-letter runs are dropped, not kept as empty tokens. */ /**
function tokenize(text: string): string[] { * Naive French stemmer French regular plurals are overwhelmingly just
* "+s" on the singular (`"carotte"`/`"carottes"`, `"pomme"`/`"pommes"`),
* unlike English's several suffix patterns, so this only strips a single
* trailing "s". Deliberately *not* {@link stemWordEn}'s `"es"` rule reused
* here: applying it to French would silently corrupt any word whose
* singular itself ends in "e" plus a consonant before the final "s" e.g.
* `"carottes"` would wrongly stem to `"carott"` (dropping the "e" that's
* actually part of the singular `"carotte"`) instead of `"carotte"`,
* exactly the class of near-miss that made ingredient matching
* French-locale silently broken before this stemmer existed (almost every
* regular French plural ends in "es" this way it's not an edge case).
* Irregular plurals (`"cheval"`/`"chevaux"`, `"chou"`/`"choux"`) aren't
* handled same "consistent, not linguistically perfect" trade-off as
* {@link stemWordEn}.
*/
function stemWordFr(word: string): string {
if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1);
return word;
}
/** Dispatches to {@link stemWordEn}/{@link stemWordFr} by `locale` — any locale other than `"fr"` uses the English rules (the long-standing default, unchanged for every existing caller that doesn't pass a locale at all). */
function stemWord(word: string, locale: string): string {
return locale === "fr" ? stemWordFr(word) : stemWordEn(word);
}
/** Lowercases, strips diacritics (via `normalizeText`) and splits `text` into stemmed word tokens — empty/non-letter runs are dropped, not kept as empty tokens. `locale` picks the stemming rules (see {@link stemWord}); defaults to `"en"`, the original behavior every pre-existing caller still gets without passing one. */
function tokenize(text: string, locale = "en"): string[] {
return normalizeText(text) return normalizeText(text)
.split(/[^a-z]+/) .split(/[^a-z]+/)
.filter((word) => word.length > 0) .filter((word) => word.length > 0)
.map(stemWord); .map((word) => stemWord(word, locale));
} }
/** Whether `needle` appears as a contiguous run inside `haystack`, at any starting position. */ /** Whether `needle` appears as a contiguous run inside `haystack`, at any starting position. */
@ -82,22 +111,193 @@ function containsSubsequence(haystack: string[], needle: string[]): boolean {
return false; return false;
} }
/** One stemmed word from {@link tokenizeWithOffsets}, alongside its `[start, end)` span in the *original* (un-normalized) text it came from. */
interface OffsetToken {
word: string;
start: number;
end: number;
}
/** Matches a run of letters (any script, diacritics included) — the same "word" unit {@link tokenize} splits `normalizeText`'d text on (`/[^a-z]+/`), applied here directly to the *original* text instead so each token keeps its real character offsets. Digits/punctuation are never part of a run, same separator role they play for `tokenize` (a leading quantity is `extractQuantity`'s job, not this module's word-tokenizer's). */
const LETTER_RUN_PATTERN = /\p{L}+/gu;
/**
* {@link tokenize}'s positional twin: same stemmed/normalized words, but
* each one keeps the `[start, end)` span it occupies in `text` needed by
* {@link findIngredientMentions} to report *where* a mention is, not just
* that the catalog has a matching label somewhere. Splitting the original
* text into letter-runs first (rather than normalizing the whole string up
* front, the way `tokenize` does, then losing track of offsets) works
* safely here because `normalizeText` only ever rewrites a character's own
* form (case/diacritics) see `_DiacriticsNormalizer`'s doc comment on the
* Python side, ported from the same guarantee never merges or splits
* words, so normalizing one already-isolated run in place can't shift its
* boundaries relative to the un-normalized text.
*/
function tokenizeWithOffsets(text: string, locale = "en"): OffsetToken[] {
const tokens: OffsetToken[] = [];
for (const match of text.matchAll(LETTER_RUN_PATTERN)) {
const raw = match[0];
const start = match.index ?? 0;
const word = stemWord(normalizeText(raw), locale);
if (word.length === 0) continue;
tokens.push({ word, start, end: start + raw.length });
}
return tokens;
}
/**
* Matches a quantity (integer/decimal/fraction/mixed number, same shapes as
* {@link extractQuantity}) immediately followed by an optional unit
* word/phrase (up to three words, e.g. "cuillères à soupe") and an optional
* connector ("de"/"d'"/"of"/"a"/"an"), anchored at the *end* of whatever
* string it's tested against (`$`) rather than the start. Anchoring at the
* end not the start is what lets {@link findQuantityBeforeIngredient}
* test the *whole* text preceding a mention without first having to guess
* where an unrelated preamble ("ajouter", "puis", an earlier sentence) ends
* and the quantity phrase begins: whatever doesn't fit the pattern
* immediately before the ingredient simply isn't part of the match, no
* separate boundary-finding step needed.
*/
const QUANTITY_BEFORE_INGREDIENT_PATTERN =
/(\d+\s+\d+\/\d+|\d+\/\d+|\d+(?:[.,]\d+)?)\s*((?:\p{L}+\s+){0,2}\p{L}*)\s*(?:de\s|d[']|of\s|a\s|an\s)?$/u;
/**
* Best-effort quantity+unit lookup for an ingredient mention {@link findIngredientMentions}
* just found at `mentionStart` in `text` looks *only* at what immediately
* precedes the mention (see {@link QUANTITY_BEFORE_INGREDIENT_PATTERN}), the
* dominant French/English recipe phrasing ("200g de beurre", "2 cuillères à
* soupe d'huile", "3 œufs"). Both `null` when nothing recognizable precedes
* it (no leading digit at all) same "no match, not an error" posture as
* {@link extractQuantity}. Doesn't detect a quantity that *follows* its
* ingredient ("du beurre, 50g") an accepted gap, same trade-off
* {@link extractQuantity} already documents for the leading-only case it
* was built for.
*/
function findQuantityBeforeIngredient(
text: string,
mentionStart: number,
unitCatalog: UnitMatchEntry[],
locale: string,
): { quantity: number | null; unitId: number | null } {
const match = QUANTITY_BEFORE_INGREDIENT_PATTERN.exec(text.slice(0, mentionStart));
if (!match) return { quantity: null, unitId: null };
const { quantity } = extractQuantity(match[1] ?? "");
const unitId = matchUnit(match[2] ?? "", unitCatalog, locale);
return { quantity, unitId };
}
/** One ingredient mention {@link findIngredientMentions} found in a free-text clause, alongside its `[start, end)` span (same convention as `TechStepMatch`, `tech-step-matcher.ts`) and any quantity+unit resolved immediately before it (see {@link findQuantityBeforeIngredient}) — both `null` when the clause names the ingredient with no quantity ("ajouter le sel"). */
export interface IngredientMention {
ingredientId: number;
start: number;
end: number;
quantity: number | null;
unitId: number | null;
}
/**
* Scans `text` (typically one technique's clause, see `tech-step-matcher.ts`'s
* `splitIntoClauses`) for every mention of a catalog ingredient, left to
* right, non-overlapping the free-text-*scanning* counterpart to
* {@link matchIngredientName} (which resolves one *already-isolated*
* ingredient-line string to a single winner, not several mentions spread
* across a longer text). Same "longest catalog label wins" rule as
* {@link matchIngredientName}, applied at every token position in turn: once
* a mention is found, scanning resumes right after it rather than
* considering a shorter label starting inside an already-matched longer one.
*
* `locale` must match whatever `ingredientCatalog`/`unitCatalog` were loaded
* in (see {@link loadIngredientCatalog}/{@link loadUnitCatalog}) defaults
* to `"en"`, same as every other function in this module.
*/
export function findIngredientMentions(
text: string,
ingredientCatalog: IngredientMatchEntry[],
unitCatalog: UnitMatchEntry[],
locale = "en",
): IngredientMention[] {
const tokens = tokenizeWithOffsets(text, locale);
if (tokens.length === 0) return [];
const candidates = ingredientCatalog
.map((entry) => ({
ingredientId: entry.ingredientId,
labelTokens: tokenize(entry.label, locale),
}))
.filter((entry) => entry.labelTokens.length > 0);
const mentions: IngredientMention[] = [];
let i = 0;
while (i < tokens.length) {
let best: { ingredientId: number; tokenCount: number } | null = null;
for (const candidate of candidates) {
const { labelTokens } = candidate;
if (i + labelTokens.length > tokens.length) continue;
const matches = labelTokens.every((word, offset) => tokens[i + offset]?.word === word);
if (!matches) continue;
if (
best === null ||
labelTokens.length > best.tokenCount ||
(labelTokens.length === best.tokenCount && candidate.ingredientId < best.ingredientId)
) {
best = { ingredientId: candidate.ingredientId, tokenCount: labelTokens.length };
}
}
if (best === null) {
i += 1;
continue;
}
const startToken = tokens[i];
const endToken = tokens[i + best.tokenCount - 1];
if (startToken === undefined || endToken === undefined) {
// Unreachable — `best` was only ever set above after confirming
// `i + labelTokens.length <= tokens.length`, so both tokens exist.
// Satisfies `noUncheckedIndexedAccess`.
i += 1;
continue;
}
const { quantity, unitId } = findQuantityBeforeIngredient(
text,
startToken.start,
unitCatalog,
locale,
);
mentions.push({
ingredientId: best.ingredientId,
start: startToken.start,
end: endToken.end,
quantity,
unitId,
});
i += best.tokenCount;
}
return mentions;
}
/** /**
* Resolves free-text `name` (a `ParsedRecipeIngredient.name`, e.g. `"large * Resolves free-text `name` (a `ParsedRecipeIngredient.name`, e.g. `"large
* diced yellow onions"`) to the best-matching `Ingredient` in `catalog`, or * diced yellow onions"`) to the best-matching `Ingredient` in `catalog`, or
* `null` if nothing matches. Among every catalog entry whose label's words * `null` if nothing matches. Among every catalog entry whose label's words
* all appear as a contiguous run in `name`, the one with the most words * all appear as a contiguous run in `name`, **in the same order**, the one
* wins (most specific "chicken breast" over bare "chicken"); ties break * with the most words wins (most specific "chicken breast" over bare
* on the lowest `ingredientId`, for a deterministic result independent of * "chicken"); ties break on the lowest `ingredientId`, for a deterministic
* catalog order. * result independent of catalog order. `locale` must match whatever
* `catalog`'s labels were loaded in (see {@link loadIngredientCatalog})
* defaults to `"en"`.
*/ */
export function matchIngredientName(name: string, catalog: IngredientMatchEntry[]): number | null { export function matchIngredientName(
const nameTokens = tokenize(name); name: string,
catalog: IngredientMatchEntry[],
locale = "en",
): number | null {
const nameTokens = tokenize(name, locale);
if (nameTokens.length === 0) return null; if (nameTokens.length === 0) return null;
let best: { ingredientId: number; tokenCount: number } | null = null; let best: { ingredientId: number; tokenCount: number } | null = null;
for (const entry of catalog) { for (const entry of catalog) {
const labelTokens = tokenize(entry.label); const labelTokens = tokenize(entry.label, locale);
if (!containsSubsequence(nameTokens, labelTokens)) continue; if (!containsSubsequence(nameTokens, labelTokens)) continue;
if ( if (
best === null || best === null ||
@ -114,24 +314,44 @@ export function matchIngredientName(name: string, catalog: IngredientMatchEntry[
} }
/** /**
* Resolves free-text `unitText` (e.g. `"tbsp"`, `"Cups"`) to the matching * Resolves free-text `unitText` (e.g. `"tbsp"`, `"Cups"`, `"cuillères à
* `Unit` in `catalog`, or `null` if nothing matches. A unit is a single * soupe de farine"`) to the best-matching `Unit` in `catalog`, or `null` if
* word by convention (see `UNIT_LABELS_EN`), so this is a whole-token * nothing matches. Same ordered-contiguous-run search as
* equality check (after stemming/normalizing), not the substring search * {@link matchIngredientName}, longest match wins **not** the
* `matchIngredientName` does `"cup"` shouldn't match inside an unrelated * single-first-word equality check this function used before French
* longer word. * support existed: every English unit synonym happens to be one word, so
* comparing only `unitText`'s first token against each *whole* synonym
* string used to be enough, but a French unit can be genuinely multi-word
* (`"cuillère à soupe"`, see `UNIT_LABELS_FR`) a whole multi-word phrase
* (spaces and all) can never equal a single extracted token, so that
* approach would have silently matched nothing for any French unit
* requiring more than one word. `locale` must match whatever `catalog`'s
* synonyms were loaded in (see {@link loadUnitCatalog}) defaults to
* `"en"`.
*/ */
export function matchUnit(unitText: string, catalog: UnitMatchEntry[]): number | null { export function matchUnit(
const tokens = tokenize(unitText); unitText: string,
if (tokens.length === 0) return null; catalog: UnitMatchEntry[],
const firstToken = tokens[0]; locale = "en",
): number | null {
const textTokens = tokenize(unitText, locale);
if (textTokens.length === 0) return null;
let best: { unitId: number; tokenCount: number } | null = null;
for (const entry of catalog) { for (const entry of catalog) {
if (entry.synonyms.some((synonym) => stemWord(normalizeText(synonym)) === firstToken)) { for (const synonym of entry.synonyms) {
return entry.unitId; const synonymTokens = tokenize(synonym, locale);
if (!containsSubsequence(textTokens, synonymTokens)) continue;
if (
best === null ||
synonymTokens.length > best.tokenCount ||
(synonymTokens.length === best.tokenCount && entry.unitId < best.unitId)
) {
best = { unitId: entry.unitId, tokenCount: synonymTokens.length };
} }
} }
return null; }
return best?.unitId ?? null;
} }
/** What {@link extractQuantity} pulls out of a leading numeric expression, alongside what's left of the string after it. */ /** What {@link extractQuantity} pulls out of a leading numeric expression, alongside what's left of the string after it. */
@ -143,7 +363,10 @@ export interface ExtractedQuantity {
// Leading "1 1/2", "1/2", "1.5", "1,5" or "2" (optionally followed by a // Leading "1 1/2", "1/2", "1.5", "1,5" or "2" (optionally followed by a
// hyphenated range like "2-3", in which case only the first number counts — // hyphenated range like "2-3", in which case only the first number counts —
// good enough for a best-effort quantity, not meant to model ranges. // good enough for a best-effort quantity, not meant to model ranges. The
// `[.,]` decimal separator already covers French recipe text ("1,5") as-is,
// same pattern used for English ("1.5") — no locale-specific handling
// needed here, unlike tokenize/stemWord above.
const LEADING_QUANTITY_PATTERN = /^(\d+)\s+(\d+)\/(\d+)|^(\d+)\/(\d+)|^(\d+(?:[.,]\d+)?)/; const LEADING_QUANTITY_PATTERN = /^(\d+)\s+(\d+)\/(\d+)|^(\d+)\/(\d+)|^(\d+(?:[.,]\d+)?)/;
/** /**
@ -176,18 +399,42 @@ export function extractQuantity(rawText: string): ExtractedQuantity {
return { quantity, remainder: trimmed.slice(match[0].length).trim() }; return { quantity, remainder: trimmed.slice(match[0].length).trim() };
} }
/** Loads the full `Ingredient` catalog as {@link IngredientMatchEntry}s — one entry per key with an authored English label (see `INGREDIENT_LABELS_EN`), plus one extra entry per alternate wording (`INGREDIENT_LABEL_SYNONYMS_EN`, e.g. "Vanilla pod" alongside "Vanilla bean" — see issue #54) sharing the same `ingredientId`; `matchIngredientName` doesn't need to know synonyms exist, it just sees more candidate labels for the same ingredient. An ingredient with no English label yet is silently skipped, never a matching target. Meant to be fetched once per request and reused across every ingredient line, not re-queried per line. */ /**
export async function loadIngredientCatalog(): Promise<IngredientMatchEntry[]> { * Per-locale matching label tables {@link loadIngredientCatalog}/
* {@link loadUnitCatalog} pick from the only two locales with any
* matching data authored yet (see `packages/shared/src/data/`). A locale
* with no entry here (anything but `"en"`/`"fr"`) falls back to empty
* tables in both loaders below the same "no matching-language data,
* degrade to doing nothing rather than guess" behavior `tech-step-matcher.ts`
* already has for a locale with no trained mappings, not a thrown error.
*/
const INGREDIENT_LABELS_BY_LOCALE: Record<string, Record<string, string>> = {
en: INGREDIENT_LABELS_EN,
fr: INGREDIENT_LABELS_FR,
};
const INGREDIENT_LABEL_SYNONYMS_BY_LOCALE: Record<string, Record<string, string[]>> = {
en: INGREDIENT_LABEL_SYNONYMS_EN,
fr: INGREDIENT_LABEL_SYNONYMS_FR,
};
const UNIT_LABELS_BY_LOCALE: Record<string, Record<string, string[]>> = {
en: UNIT_LABELS_EN,
fr: UNIT_LABELS_FR,
};
/** Loads the full `Ingredient` catalog as {@link IngredientMatchEntry}s for `locale` (default `"en"`) — one entry per key with an authored label in that locale, plus one extra entry per alternate wording (`INGREDIENT_LABEL_SYNONYMS_EN`/`_FR`, e.g. "Vanilla pod" alongside "Vanilla bean" — see issue #54) sharing the same `ingredientId`; `matchIngredientName` doesn't need to know synonyms exist, it just sees more candidate labels for the same ingredient. An ingredient with no label in `locale` yet is silently skipped, never a matching target — same for every ingredient when `locale` itself has no label table at all (see {@link INGREDIENT_LABELS_BY_LOCALE}). Meant to be fetched once per request and reused across every ingredient line, not re-queried per line. */
export async function loadIngredientCatalog(locale = "en"): Promise<IngredientMatchEntry[]> {
try { try {
const labels = INGREDIENT_LABELS_BY_LOCALE[locale] ?? {};
const synonyms = INGREDIENT_LABEL_SYNONYMS_BY_LOCALE[locale] ?? {};
const ingredients = await prisma.ingredient.findMany({ const ingredients = await prisma.ingredient.findMany({
select: { id: true, key: true }, select: { id: true, key: true },
}); });
const catalog: IngredientMatchEntry[] = []; const catalog: IngredientMatchEntry[] = [];
for (const ingredient of ingredients) { for (const ingredient of ingredients) {
const label = INGREDIENT_LABELS_EN[ingredient.key]; const label = labels[ingredient.key];
if (label === undefined) continue; if (label === undefined) continue;
catalog.push({ ingredientId: ingredient.id, label }); catalog.push({ ingredientId: ingredient.id, label });
for (const synonym of INGREDIENT_LABEL_SYNONYMS_EN[ingredient.key] ?? []) { for (const synonym of synonyms[ingredient.key] ?? []) {
catalog.push({ ingredientId: ingredient.id, label: synonym }); catalog.push({ ingredientId: ingredient.id, label: synonym });
} }
} }
@ -200,15 +447,16 @@ export async function loadIngredientCatalog(): Promise<IngredientMatchEntry[]> {
} }
} }
/** Loads the full `Unit` catalog as {@link UnitMatchEntry}s — one entry per key with authored English synonyms (see `UNIT_LABELS_EN`); a unit with none yet is silently skipped. Meant to be fetched once per request, same reasoning as {@link loadIngredientCatalog}. */ /** Loads the full `Unit` catalog as {@link UnitMatchEntry}s for `locale` (default `"en"`) — one entry per key with authored synonyms in that locale (see {@link UNIT_LABELS_BY_LOCALE}); a unit with none yet is silently skipped. Meant to be fetched once per request, same reasoning as {@link loadIngredientCatalog}. */
export async function loadUnitCatalog(): Promise<UnitMatchEntry[]> { export async function loadUnitCatalog(locale = "en"): Promise<UnitMatchEntry[]> {
try { try {
const labels = UNIT_LABELS_BY_LOCALE[locale] ?? {};
const units = await prisma.unit.findMany({ const units = await prisma.unit.findMany({
select: { id: true, key: true }, select: { id: true, key: true },
}); });
const catalog: UnitMatchEntry[] = []; const catalog: UnitMatchEntry[] = [];
for (const unit of units) { for (const unit of units) {
const synonyms = UNIT_LABELS_EN[unit.key]; const synonyms = labels[unit.key];
if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms }); if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms });
} }
return catalog; return catalog;

View file

@ -0,0 +1,102 @@
import { env } from "../../config/env.js";
/**
* Thin fetch wrapper around `services/tech-step-intent-service`'s HTTP
* contract (`POST /v1/process`) the microservice
* {@link TechStepClassifierService} (`tech-step-matcher.ts`) delegates NER +
* intent classification to, in place of the `node-nlp` `NlpManager` it used
* to own directly. See that service's own README for the full contract and
* why it never touches Postgres itself it also owns its own training
* corpus now (`training_data.py`), trained once at its own startup, so
* `apps/api` never pushes anything to it; `process()` below is this
* client's only method.
*
* Authenticated with `INTENT_SERVICE_SECRET` the inverse direction of
* `requireInternalWorker`'s `INTERNAL_WORKER_SECRET` (this time `apps/api`
* is the caller, not the callee), but the same "one flat shared secret"
* shape.
*/
/** One candidate mention one of the service's two `PhraseMatcher`s found — offsets `[start, end)`, same convention as `String.prototype.slice`. Mirrors `EntityPayload` (Python `schemas.py`). `kind` distinguishes a technique mention (`self._matcher`, the corpus-trained one) from a utensil mention (`self._utensil_matcher`, static — see `utensil_vocabulary.py`) — `tech-step-matcher.ts` resolves each against a different catalog (`TechStep`/`Utensil`). */
export interface IntentServiceEntity {
uid: string;
start: number;
end: number;
kind: "technique" | "utensil";
}
/** The full result of a `POST /v1/process` call — mirrors `ProcessResponse` (Python `schemas.py`). `intent` is `null` only when `locale` isn't one this service trains for, or `text` is blank; otherwise always a real `uid` (the Python service's `textcat` has no "None" sentinel, unlike node-nlp — see that service's README). */
export interface IntentServiceProcessResult {
entities: IntentServiceEntity[];
intent: string | null;
score: number;
}
/**
* Client for `services/tech-step-intent-service` a real class (not a
* plain object of functions) per this repo's service-style-logic
* convention, even though it holds no state of its own: it's used as the
* one shared {@link intentServiceClient} singleton below, same reasoning as
* `TechStepClassifierService` itself.
*/
export class IntentServiceClient {
/**
* Performs a JSON request against the intent service and returns the
* parsed body.
*
* @throws {Error} if the response status is not in the 2xx range, or the
* request itself fails (network error, service down) left as a plain
* `Error` rather than a typed `HttpError`: this is an internal
* service-to-service call, not a request `apps/api`'s own HTTP layer
* needs to map to a client-facing status code (see
* `TechStepClassifierService.warmUp`'s retry in `server.ts` for how a
* failure here is actually handled).
*/
private async _request<TResponseBody>(
path: string,
init: RequestInit = {},
): Promise<TResponseBody> {
try {
const response = await fetch(`${env.INTENT_SERVICE_BASE_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
"X-Intent-Service-Secret": env.INTENT_SERVICE_SECRET,
...init.headers,
},
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`${init.method ?? "GET"} ${path} failed: ${response.status} ${body}`);
}
return (await response.json()) as TResponseBody;
} catch (err) {
// Rethrown as-is — every caller (`TechStepClassifierService`) already
// wraps its own `await`s per the repo's try/catch convention; this is
// just where the `await` itself has to sit inside one.
throw err;
}
}
/**
* Equivalent to the old `NlpManager.process(locale, text)` returns every
* candidate technique mention (NER) plus the intent classifier's verdict
* for `text` as a whole, whether `text` is a full step description or a
* single clause `TechStepClassifierService` already cut out of one (this
* service doesn't know or care which, exactly like `NlpManager` before
* it).
*/
public async process(locale: string, text: string): Promise<IntentServiceProcessResult> {
try {
return await this._request("/v1/process", {
method: "POST",
body: JSON.stringify({ locale, text }),
});
} catch (err) {
throw err;
}
}
}
/** Single shared instance — stateless, no reason for more than one (same reasoning as `techStepClassifier`/`prisma`). */
export const intentServiceClient = new IntentServiceClient();

View file

@ -100,12 +100,19 @@ export async function translateRecipeSteps(
} }
/** /**
* English label {@link matchUnit} is fed when a quantity was found but no * Locale-specific label {@link matchUnit} is fed when a quantity was found
* unit word was see the `unitId` fallback below. `"piece"` (`UNIT_LABELS_EN`, * but no unit word was see the `unitId` fallback below. Each is the
* `packages/shared`) is the catalog's generic "counted, no further unit" * catalog's generic "counted, no further unit" entry (`Unit.key` `"piece"`)
* entry (French "unité"). * in that locale's own label table (`UNIT_LABELS_EN`/`UNIT_LABELS_FR`,
* `packages/shared`). A locale with neither entry (anything but
* `"en"`/`"fr"`) falls back to the English spelling harmless, since
* `unitCatalog` itself is already empty for an unsupported locale (see
* `loadUnitCatalog`), so this fallback lookup finds nothing either way.
*/ */
const FALLBACK_COUNT_UNIT_LABEL = "piece"; const FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE: Record<string, string> = {
en: "piece",
fr: "unité",
};
/** /**
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity` * Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
@ -127,20 +134,28 @@ const FALLBACK_COUNT_UNIT_LABEL = "piece";
* button disabled with no indication why on almost any recipe with a * button disabled with no indication why on almost any recipe with a
* whole-item ingredient. No fallback when `quantity` itself is `null` * whole-item ingredient. No fallback when `quantity` itself is `null`
* (e.g. `"To taste"`) there's nothing to count, so nothing to default. * (e.g. `"To taste"`) there's nothing to count, so nothing to default.
*
* `locale` (default `"en"`, matching every pre-existing caller) must agree
* with whichever locale `ingredientCatalog`/`unitCatalog` were loaded in
* (see `loadIngredientCatalog`/`loadUnitCatalog`) it's threaded through to
* `matchIngredientName`/`matchUnit` for stemming, and picks the right
* spelling of the "piece" fallback below.
*/ */
export function translateRecipeIngredients( export function translateRecipeIngredients(
ingredients: ParsedRecipeIngredient[], ingredients: ParsedRecipeIngredient[],
ingredientCatalog: IngredientMatchEntry[], ingredientCatalog: IngredientMatchEntry[],
unitCatalog: UnitMatchEntry[], unitCatalog: UnitMatchEntry[],
locale = "en",
): TranslatedRecipeIngredient[] { ): TranslatedRecipeIngredient[] {
const fallbackCountUnitLabel = FALLBACK_COUNT_UNIT_LABEL_BY_LOCALE[locale] ?? "piece";
return ingredients.map((ingredient) => { return ingredients.map((ingredient) => {
const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog); const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog, locale);
const extracted = extractQuantity(ingredient.rawText); const extracted = extractQuantity(ingredient.rawText);
const quantity = ingredient.quantity ?? extracted.quantity; const quantity = ingredient.quantity ?? extracted.quantity;
const unitText = ingredient.unit ?? extracted.remainder; const unitText = ingredient.unit ?? extracted.remainder;
const unitId = const unitId =
matchUnit(unitText, unitCatalog) ?? matchUnit(unitText, unitCatalog, locale) ??
(quantity !== null ? matchUnit(FALLBACK_COUNT_UNIT_LABEL, unitCatalog) : null); (quantity !== null ? matchUnit(fallbackCountUnitLabel, unitCatalog, locale) : null);
return { ...ingredient, quantity, ingredientId, unitId }; return { ...ingredient, quantity, ingredientId, unitId };
}); });
} }
@ -273,13 +288,19 @@ export function mergeDuplicateIngredients(
* locale that doesn't match the actual text's language doesn't degrade * locale that doesn't match the actual text's language doesn't degrade
* gracefully, it just gets things wrong. * gracefully, it just gets things wrong.
* *
* Ingredient/unit matching only has English data today * Ingredient/unit matching has data for `"en"` and `"fr"` today
* (`INGREDIENT_LABELS_EN`/`UNIT_LABELS_EN`, `packages/shared`) for any * (`INGREDIENT_LABELS_EN`/`_FR`, `UNIT_LABELS_EN`/`_FR`, `packages/shared`)
* `locale` other than `"en"` this skips `loadIngredientCatalog`/ * `loadIngredientCatalog(locale)`/`loadUnitCatalog(locale)` are always
* `loadUnitCatalog` entirely and leaves every ingredient's `ingredientId`/ * called, never specially skipped for a particular locale: a locale with no
* `unitId` at the neutral `null` `translateRecipeSteps` already stubs in, * label table of its own (anything but `"en"`/`"fr"`) just gets back empty
* the same "no matching-language data" degradation tech-step matching * catalogs from those two loaders, and `translateRecipeIngredients` over an
* already has for a locale with no mappings. * empty catalog naturally leaves every ingredient's `ingredientId`/`unitId`
* at the neutral `null` `translateRecipeSteps` already stubs in the same
* "no matching-language data" degradation tech-step matching already has
* for a locale with no mappings, just arrived at by *not* special-casing
* which locales are "supported" here at all (that's `INGREDIENT_LABELS_BY_LOCALE`/
* `UNIT_LABELS_BY_LOCALE`'s job, in `ingredient-matcher.ts` this function
* doesn't need its own copy of that list to stay in sync with).
*/ */
export async function translateRecipe( export async function translateRecipe(
recipe: ParsedRecipe, recipe: ParsedRecipe,
@ -288,15 +309,18 @@ export async function translateRecipe(
try { try {
const translated = await translateRecipeSteps(recipe, locale); const translated = await translateRecipeSteps(recipe, locale);
if (locale !== "en") return translated;
const [ingredientCatalog, unitCatalog] = await Promise.all([ const [ingredientCatalog, unitCatalog] = await Promise.all([
loadIngredientCatalog(), loadIngredientCatalog(locale),
loadUnitCatalog(), loadUnitCatalog(locale),
]); ]);
return { return {
...translated, ...translated,
ingredients: translateRecipeIngredients(recipe.ingredients, ingredientCatalog, unitCatalog), ingredients: translateRecipeIngredients(
recipe.ingredients,
ingredientCatalog,
unitCatalog,
locale,
),
}; };
} catch (err) { } catch (err) {
// Rethrown as-is — the caller (`sources.service.ts`) already // Rethrown as-is — the caller (`sources.service.ts`) already

View file

@ -0,0 +1,263 @@
/**
* Hand-labeled evaluation set for {@link techStepClassifier} what
* `tech-step-eval.test.ts` runs the real classifier against to compute
* precision/recall/F1 (`tech-step-evaluator.ts`), the objective gate any
* future change to `services/tech-step-intent-service`'s `training_data.py`
* must clear (see that module's own doc comment).
*
* Deliberately *not* reusing `TECH_STEP_TRAINING_DATA`'s own `utterances`
* verbatim scoring the classifier against the exact sentences it was
* trained on would measure memorization, not generalization. Every
* description below is original phrasing; where a case still needs to name
* a technique's own verb to be labeled with confidence (most of them, see
* this file's own limits below), it's at least a different sentence shape
* than anything in the training corpus.
*
* `expectedKeys` is a multiset in reading order (see
* `TechStepEvalOutcome`'s doc comment in `tech-step-evaluator.ts` for why
* order isn't scored but repetition is) of `TechStep.key`s resolved to
* real DB ids and back by `tech-step-eval-runner.ts`, this file only ever
* deals in stable keys so it doesn't need DB access to author or read.
*
* Known limit of this dataset, confirmed against a real run (see
* `MIN_OVERALL_F1`'s own doc comment, `tech-step-eval-runner.ts`): most
* cases anchor on a technique's own registered synonym, but `_classifyClause`
* only falls back to that anchor when the intent classifier's own score is
* *below* `CONFIDENCE_THRESHOLD` a confidently *wrong* whole-clause
* classification (e.g. "Blanchissez les haricots verts..." scoring
* confidently as `peel` despite the correct `blanch` anchor) overrides the
* anchor just as readily as a confidently *right* one does, so this
* dataset genuinely does measure real classifier failures, not just a
* synthetic floor. A handful of such real mismatches are expected and
* intentionally left uncorrected here (see `MIN_OVERALL_F1`'s doc comment)
* fixing the classifier's actual behavior on them is corpus work for a
* future change, not something to hide by loosening this dataset's own
* expectations to match whatever it currently outputs.
*/
export interface TechStepEvalCase {
description: string;
locale: "fr" | "en";
expectedKeys: string[];
}
export const TECH_STEP_EVAL_DATASET: TechStepEvalCase[] = [
// --- One straightforward case per technique (fr), covering all 26 ---
{
description: "Faites cuire les pâtes al dente dans une grande casserole d'eau bien salée.",
locale: "fr",
expectedKeys: ["cook"],
},
{
description: "Faites bouillir l'eau dans une grande casserole avant d'y plonger les pâtes.",
locale: "fr",
expectedKeys: ["boil"],
},
{
description: "Plongez les beignets dans l'huile très chaude pour les faire frire.",
locale: "fr",
expectedKeys: ["fry"],
},
{
description: "Faites fondre le chocolat noir au bain-marie en remuant.",
locale: "fr",
expectedKeys: ["melt"],
},
{
description: "Déglacez la casserole avec un trait de vinaigre balsamique.",
locale: "fr",
expectedKeys: ["deglaze"],
},
{
description: "Laissez frémir la sauce tomate vingt minutes à couvert.",
locale: "fr",
expectedKeys: ["simmer"],
},
{
description: "Faites rôtir la volaille entière sur la broche du four.",
locale: "fr",
expectedKeys: ["roast"],
},
{
description: "Faites griller les brochettes de poulet quelques minutes de chaque côté.",
locale: "fr",
expectedKeys: ["grill"],
},
{
description: "Faites sauter les champignons à feu vif dans une poêle très chaude.",
locale: "fr",
expectedKeys: ["panFry"],
},
{
description: "Blanchissez les haricots verts trois minutes avant de les refroidir.",
locale: "fr",
expectedKeys: ["blanch"],
},
{
description: "Laissez mariner les brochettes de poulet deux heures au frais.",
locale: "fr",
expectedKeys: ["marinate"],
},
{
description: "Hachez grossièrement le persil frais avant de le parsemer.",
locale: "fr",
expectedKeys: ["chop"],
},
{
description: "Épluchez les carottes avant de les couper en rondelles.",
locale: "fr",
expectedKeys: ["peel"],
},
{
description: "Émincez finement l'échalote pour la vinaigrette.",
locale: "fr",
expectedKeys: ["mince"],
},
{
description: "Mélangez la farine, le sucre et les œufs dans un grand saladier.",
locale: "fr",
expectedKeys: ["mix"],
},
{
description: "Fouettez énergiquement la crème jusqu'à ce qu'elle épaississe.",
locale: "fr",
expectedKeys: ["whisk"],
},
{
description: "Incorporez délicatement la farine tamisée à la préparation.",
locale: "fr",
expectedKeys: ["foldIn"],
},
{
description: "Réservez la pâte au réfrigérateur pendant que vous préparez la garniture.",
locale: "fr",
expectedKeys: ["setAside"],
},
{
description: "Assaisonnez le poisson avec du sel, du poivre et un filet de citron.",
locale: "fr",
expectedKeys: ["season"],
},
{
description: "Égouttez soigneusement le riz dans une passoire fine.",
locale: "fr",
expectedKeys: ["drain"],
},
{
description:
"Faites dorer les morceaux de veau sur toutes leurs faces avant de mouiller avec le bouillon.",
locale: "fr",
expectedKeys: ["brown"],
},
{
description: "Laissez reposer la viande dix minutes avant de la trancher.",
locale: "fr",
expectedKeys: ["rest"],
},
{
description: "Préchauffez le four à 200 degrés avant d'y glisser le gratin.",
locale: "fr",
expectedKeys: ["preheat"],
},
{
description: "Enfournez la tarte pendant trente-cinq minutes jusqu'à ce qu'elle soit dorée.",
locale: "fr",
expectedKeys: ["bake"],
},
{
description: "Dressez harmonieusement les légumes autour de la pièce de viande.",
locale: "fr",
expectedKeys: ["plate"],
},
{
description: "Nappez le fond du moule d'une fine couche de caramel.",
locale: "fr",
expectedKeys: ["coat"],
},
// --- English coverage (same technique verbs, distinct sentences) ---
{
description: "Simmer the stock gently for forty minutes, skimming occasionally.",
locale: "en",
expectedKeys: ["simmer"],
},
{
description: "Peel the potatoes and rinse them under cold water.",
locale: "en",
expectedKeys: ["peel"],
},
{
description: "Whisk the eggs with a pinch of salt until frothy.",
locale: "en",
expectedKeys: ["whisk"],
},
{
description: "Season the soup generously with black pepper before serving.",
locale: "en",
expectedKeys: ["season"],
},
{
description: "Make sure the chicken is cooked through before serving.",
locale: "en",
expectedKeys: ["cook"],
},
// --- Multi-technique sentences, in reading order ---
{
description: "Préchauffez le four, puis faites rôtir le poulet pendant une heure.",
locale: "fr",
expectedKeys: ["preheat", "roast"],
},
{
description: "Faites revenir les oignons, puis déglacez la poêle avec du vin blanc.",
locale: "fr",
expectedKeys: ["brown", "deglaze"],
},
{
description:
"Faites cuire les légumes à la vapeur, puis assaisonnez-les avec des herbes fraîches.",
locale: "fr",
expectedKeys: ["cook", "season"],
},
{
description:
"Émincez l'oignon, faites-le suer, puis mouillez avec le bouillon et laissez mijoter.",
locale: "fr",
expectedKeys: ["mince", "simmer"],
},
// --- No technique mentioned at all ---
{
description: "Répartissez les convives autour de la table avant de commencer le repas.",
locale: "fr",
expectedKeys: [],
},
{
description: "Rangez les couverts propres dans le tiroir de la cuisine.",
locale: "fr",
expectedKeys: [],
},
{
description: "Take the dishes and glasses out of the cupboard.",
locale: "en",
expectedKeys: [],
},
// --- Documented false-positive traps, re-verified with fresh wording ---
// `brown`'s EN synonyms are verb forms only ("browned"/"browning"), not
// bare "brown" — precisely so this doesn't false-positive (see that
// entry's own comment in training_data.py).
{
description: "This recipe calls for two tablespoons of brown sugar.",
locale: "en",
expectedKeys: [],
},
// `rest`'s EN synonyms are anchored phrases ("let it rest"/"resting
// for"...), not bare "rest" — so a sentence using the word in its
// "remainder" sense must not anchor `rest` at all.
{
description: "There is no time to rest before the guests arrive.",
locale: "en",
expectedKeys: [],
},
];

View file

@ -0,0 +1,71 @@
import { prisma } from "../../db/prisma.js";
import { TECH_STEP_EVAL_DATASET } from "./tech-step-eval-dataset.js";
import {
computeTechStepMetrics,
type TechStepEvalOutcome,
type TechStepEvalResult,
} from "./tech-step-evaluator.js";
import { techStepClassifier } from "./tech-step-matcher.js";
/**
* Regression floor (not a target) both `runTechStepEvalSuite`'s consumers
* gate on exported from here (not defined separately in each consumer)
* so the CI regression gate and `scripts/retrain-tech-steps.ts`'s
* pre-backfill gate can never silently drift to different thresholds.
*
* Calibrated against a real run: the classifier trained on the corpus as
* of this constant's introduction scored **0.815** aggregate F1
* (33 TP / 9 FP / 6 FN) against `TECH_STEP_EVAL_DATASET` `0.8` leaves a
* small margin below that for run-to-run noise while still catching a
* real regression (not a floor picked blind before ever running this
* suite see this feature's plan document for that earlier state). The
* mismatches this run surfaced (e.g. "Blanchissez les haricots verts..."
* misclassified as `peel`, a handful of anchor-less sentences expected to
* match nothing instead scoring confidently as some technique) are real,
* known classifier weaknesses evidence this harness is doing its job,
* not something to quietly paper over by loosening the dataset's own
* expectations. Improving them is corpus work for a future change, gated
* by this same suite.
*/
export const MIN_OVERALL_F1 = 0.8;
/**
* Runs {@link TECH_STEP_EVAL_DATASET} against the real, currently-trained
* `techStepClassifier` and returns the aggregate/per-technique metrics
* (`computeTechStepMetrics`, `tech-step-evaluator.ts`) the one place this
* DB-touching "resolve ids to keys, then score" logic lives, shared by
* `test/recipe-matching/tech-step-eval.test.ts` (this feature's CI
* regression gate) and `scripts/retrain-tech-steps.ts` (the same gate, run
* by a maintainer before applying a corpus change). Kept out of
* `tech-step-evaluator.ts` itself, which is deliberately pure/DB-free (see
* that module's own doc comment) so its scoring logic stays unit-testable
* without a database.
*/
export async function runTechStepEvalSuite(): Promise<TechStepEvalResult> {
const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
const keyById = new Map(techSteps.map((techStep) => [techStep.id, techStep.key]));
const outcomes: TechStepEvalOutcome[] = [];
for (const evalCase of TECH_STEP_EVAL_DATASET) {
const techStepIds = await techStepClassifier.matchTechSteps(
evalCase.description,
evalCase.locale,
);
const actualKeys = techStepIds.map((id) => {
const key = keyById.get(id);
// A `techStepId` the classifier resolved that isn't in the seeded
// catalog would be a bug in the classifier or the seed data, not
// this dataset — fail loudly rather than silently dropping it (see
// `_train`'s own comment in `tech-step-matcher.ts` on the
// equivalent, deliberately silent `undefined` case it has to
// tolerate for a different reason).
if (key === undefined) {
throw new Error(`Unknown TechStep id ${id} returned for "${evalCase.description}"`);
}
return key;
});
outcomes.push({ expectedKeys: evalCase.expectedKeys, actualKeys });
}
return computeTechStepMetrics(outcomes);
}

View file

@ -0,0 +1,135 @@
/**
* Precision/recall/F1 for {@link techStepClassifier}'s output against a
* hand-labeled evaluation set (`tech-step-eval-dataset.ts`) the objective
* counterpart to the "inspected by eye" verdict every corpus change used to
* get before this module existed. Every future edit to
* `services/tech-step-intent-service`'s `training_data.py` (including the
* LLM-assisted suggestions the worker in `services/tech-step-llm-worker`
* proposes) is expected to run
* through `tech-step-eval.test.ts`'s regression gate, which calls
* {@link computeTechStepMetrics} a corpus change that raises recall on one
* technique but silently tanks another's precision should fail loudly here,
* not get merged on the strength of a few manually-checked examples.
*
* Pure (no DB/model access) so it's unit-testable on its own same
* convention as `tech-step-matcher.ts`'s own pure helpers (`normalizeText`,
* `splitIntoClauses`): this module only ever receives already-resolved
* `TechStep.key` strings, never DB ids or a live classifier instance, so it
* has nothing to mock to test.
*/
/** True/false-positive/negative counts for one technique (or the aggregate across all of them), plus the precision/recall/F1 derived from them. */
export interface TechStepMetrics {
truePositives: number;
falsePositives: number;
falseNegatives: number;
precision: number;
recall: number;
f1: number;
}
/**
* One evaluation case's outcome what {@link TechStepEvalCase.expectedKeys}
* said should be found, against what the classifier actually returned for
* that case (already mapped from `TechStepMatch.techStepId` back to
* `TechStep.key`, see `tech-step-eval.test.ts`).
*
* Both lists are *multisets*, not sets a description that names the same
* technique twice (rare, but not impossible: "faire cuire, puis... remettre
* à cuire") is expected to produce two matches, and comparing as plain sets
* would silently treat a classifier that only found one of them as a
* perfect match.
*/
export interface TechStepEvalOutcome {
expectedKeys: string[];
actualKeys: string[];
}
/** {@link computeTechStepMetrics}'s result — the aggregate across every case, plus a breakdown per technique so a regression hiding behind a healthy overall F1 (one technique's recall collapsing, offset by another's improving) is still visible. */
export interface TechStepEvalResult {
overall: TechStepMetrics;
byKey: Record<string, TechStepMetrics>;
}
interface RawCounts {
tp: number;
fp: number;
fn: number;
}
function emptyCounts(): RawCounts {
return { tp: 0, fp: 0, fn: 0 };
}
/** Counts occurrences of each key in a multiset, e.g. `["cook", "cook", "bake"]` -> `{cook: 2, bake: 1}`. */
function countByKey(keys: string[]): Map<string, number> {
const counts = new Map<string, number>();
for (const key of keys) {
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return counts;
}
/**
* Standard vacuous-truth convention for the `0/0` cases: precision defaults
* to `1` when nothing was predicted for a key (`tp + fp === 0` no false
* accusation to be precise about), recall defaults to `1` when nothing was
* expected (`tp + fn === 0` nothing to have missed). Neither inflates F1
* on its own: a technique the classifier fully misses still has `recall =
* 0` (there *were* expected occurrences, just none matched), which is what
* pulls F1 down to `0` for that case regardless of precision's vacuous `1`.
*/
function toMetrics(counts: RawCounts): TechStepMetrics {
const { tp, fp, fn } = counts;
const precision = tp + fp === 0 ? 1 : tp / (tp + fp);
const recall = tp + fn === 0 ? 1 : tp / (tp + fn);
const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
return { truePositives: tp, falsePositives: fp, falseNegatives: fn, precision, recall, f1 };
}
/**
* Aggregates every {@link TechStepEvalOutcome} into one overall
* precision/recall/F1 plus a per-technique breakdown.
*
* Counted key by key, multiset-style, per outcome: for a given technique,
* `min(expectedCount, actualCount)` true positives, any actual occurrences
* beyond that are false positives, any expected occurrences short of that
* are false negatives generalizes the usual set-based TP/FP/FN definition
* to handle a technique mentioned (or matched) more than once in the same
* step without over- or under-counting it.
*/
export function computeTechStepMetrics(outcomes: TechStepEvalOutcome[]): TechStepEvalResult {
const overallCounts = emptyCounts();
const countsByKey = new Map<string, RawCounts>();
for (const outcome of outcomes) {
const expectedCounts = countByKey(outcome.expectedKeys);
const actualCounts = countByKey(outcome.actualKeys);
const allKeys = new Set([...expectedCounts.keys(), ...actualCounts.keys()]);
for (const key of allKeys) {
const expected = expectedCounts.get(key) ?? 0;
const actual = actualCounts.get(key) ?? 0;
const tp = Math.min(expected, actual);
const fp = Math.max(0, actual - expected);
const fn = Math.max(0, expected - actual);
overallCounts.tp += tp;
overallCounts.fp += fp;
overallCounts.fn += fn;
const keyCounts = countsByKey.get(key) ?? emptyCounts();
keyCounts.tp += tp;
keyCounts.fp += fp;
keyCounts.fn += fn;
countsByKey.set(key, keyCounts);
}
}
const byKey: Record<string, TechStepMetrics> = {};
for (const [key, counts] of countsByKey) {
byKey[key] = toMetrics(counts);
}
return { overall: toMetrics(overallCounts), byKey };
}

View file

@ -1,6 +1,11 @@
import { NlpManager } from "node-nlp";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js"; import {
findIngredientMentions,
type IngredientMention,
loadIngredientCatalog,
loadUnitCatalog,
} from "./ingredient-matcher.js";
import { intentServiceClient } from "./intent-service-client.js";
/** /**
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe * Auto-detects which cooking techniques (`TechStep`) a free-text recipe
@ -15,25 +20,27 @@ import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js";
* generalize past its own vocabulary a step describing melting butter as * generalize past its own vocabulary a step describing melting butter as
* "jusqu'à ce que le beurre ait disparu dans la poêle" mentions no verb any * "jusqu'à ce que le beurre ait disparu dans la poêle" mentions no verb any
* regex could anchor on, yet unmistakably *means* `melt`. Replaced with a * regex could anchor on, yet unmistakably *means* `melt`. Replaced with a
* small hybrid pipeline built on `node-nlp` ({@link TechStepClassifierService}): * small hybrid pipeline (originally built on `node-nlp`, now entirely
* delegated to `services/tech-step-intent-service` a spaCy-based
* microservice, see {@link IntentServiceClient} and that service's own
* README):
* *
* 1. **NER** (node-nlp enum entities, `synonyms` in `TECH_STEP_TRAINING_DATA`) * 1. **NER** (the intent service's `PhraseMatcher`, built from its own
* finds every *candidate* technique mention in the whole * `training_data.py`'s `synonyms`) finds every *candidate* technique
* description, each with its exact character span mechanically the * mention in the whole description, each with its exact character span
* same job the old regexes did, just as flat synonym lists instead of * mechanically the same job the old regexes did, just as flat synonym
* hand-written patterns (node-nlp's own stemmer/fuzzy matching already * lists instead of hand-written patterns. This step alone is *not* the
* covers minor conjugation/typo variance the regexes had to enumerate * final answer see step 3.
* by hand). This step alone is *not* the final answer see step 3.
* 2. The description is cut into clauses around those candidate spans * 2. The description is cut into clauses around those candidate spans
* ({@link splitIntoClauses}) a step naming two techniques ("Dans une * ({@link splitIntoClauses}) a step naming two techniques ("Dans une
* poêle chaude, faire chauffer une noix de beurre" is both `preheat` * poêle chaude, faire chauffer une noix de beurre" is both `preheat`
* and `melt`) needs each judged on its own surrounding context, not the * and `melt`) needs each judged on its own surrounding context, not the
* whole step lumped into one classification. * whole step lumped into one classification.
* 3. **NLP intent classification** (node-nlp's `NlpManager`, trained on * 3. **NLP intent classification** (the intent service's `textcat`, trained
* `TECH_STEP_TRAINING_DATA`'s `utterances`) then classifies each clause * on its own `training_data.py`'s `utterances`) then classifies each
* on its own this is what actually delivers "meaning, not keywords": * clause on its own this is what actually delivers "meaning, not
* the classifier was deliberately trained on paraphrases that never use * keywords": the classifier was deliberately trained on paraphrases that
* the technique's own verb (e.g. "jusqu'à ce que le beurre ait * never use the technique's own verb (e.g. "jusqu'à ce que le beurre ait
* disparu" for `melt`), so a clause reaching it gets labeled by what it * disparu" for `melt`), so a clause reaching it gets labeled by what it
* was trained to recognize as *meaning* a technique, not by which * was trained to recognize as *meaning* a technique, not by which
* literal word the NER step happened to anchor on. The NER-implied * literal word the NER step happened to anchor on. The NER-implied
@ -49,12 +56,12 @@ import { TECH_STEP_TRAINING_DATA } from "./tech-step-training-data.js";
* *
* `normalizeText` and {@link splitIntoClauses} are pure (no DB/model * `normalizeText` and {@link splitIntoClauses} are pure (no DB/model
* access) so they stay unit-testable in isolation (see * access) so they stay unit-testable in isolation (see
* `test/tech-step-matcher.test.ts`); the classifier itself needs a one-time * `test/tech-step-matcher.test.ts`); this class only ever needs a
* training pass (`_ensureTrained`, node-nlp's `NlpManager.train()`) plus a * `TechStep.key -> id` lookup from the DB, memoized on the shared
* `TechStep.key -> id` lookup from the DB, both memoized on the shared * {@link techStepClassifier} singleton rather than repeated per call the
* {@link techStepClassifier} singleton rather than repeated per call * NLP model itself trains once, inside `services/tech-step-intent-service`'s
* training is the expensive part (a few hundred ms for this corpus), never * own startup, entirely independently of this class (see that service's
* worth redoing per request let alone per step. * README this repo no longer pushes any corpus to it over HTTP).
*/ */
/** /**
@ -89,6 +96,15 @@ export function normalizeText(text: string): string {
* Persisted as `StepTechStep.start`/`end`/`contextStart`/`contextEnd` * Persisted as `StepTechStep.start`/`end`/`contextStart`/`contextEnd`
* (`recipe.service.ts`) so the recipe detail view can highlight both spans, * (`recipe.service.ts`) so the recipe detail view can highlight both spans,
* not just know a technique was mentioned somewhere. * not just know a technique was mentioned somewhere.
*
* `ingredients`/`utensils` are the metadata found in this match's own
* *clause* (see this file's doc comment, point 2) an ingredient/utensil
* mentioned in a different clause of the same description belongs to
* *that* clause's own match, never this one, the same "judged on its own
* surrounding context" rule the technique itself is judged by. Always `[]`
* rather than omitted when nothing was found, so every caller can iterate
* unconditionally. Persisted as `StepTechStepIngredient`/`StepTechStepUtensil`
* rows (`recipe.service.ts`).
*/ */
export interface TechStepMatch { export interface TechStepMatch {
techStepId: number; techStepId: number;
@ -96,6 +112,21 @@ export interface TechStepMatch {
end: number; end: number;
contextStart: number; contextStart: number;
contextEnd: number; contextEnd: number;
ingredients: IngredientMention[];
utensils: UtensilMention[];
}
/**
* A utensil mention found by the intent service's utensil `PhraseMatcher`
* (`kind: "utensil"` entities in `IntentServiceProcessResult`, see
* `intent-service-client.ts`), resolved to a local `Utensil.id` and
* attributed to whichever clause its span falls inside same
* `[start, end)` convention as every other span in this file.
*/
export interface UtensilMention {
utensilId: number;
start: number;
end: number;
} }
/** A candidate technique mention found by NER — the raw material {@link splitIntoClauses} cuts a description around. */ /** A candidate technique mention found by NER — the raw material {@link splitIntoClauses} cuts a description around. */
@ -241,77 +272,90 @@ export function splitIntoClauses(
* `TECH_STEP_TRAINING_DATA` see `test/tech-step-matcher.test.ts` for the * `TECH_STEP_TRAINING_DATA` see `test/tech-step-matcher.test.ts` for the
* cases this threshold was picked to pass. * cases this threshold was picked to pass.
* *
* Raised from `0.65` after finding real (non-adversarial) misclassified * Recalibrated for the migration off `node-nlp` to
* clauses that scored just above the old threshold e.g. English recipe * `services/tech-step-intent-service` (spaCy `textcat`, exclusive classes)
* text run through the French classifier (which must find *nothing*, * its score distribution is meaningfully different from node-nlp's own
* confirmed by `recipe-translation.test.ts`'s own locale-isolation test) * classifier, and shifts again every time the corpus' technique count
* scored `0.69` for `boil`, essentially classifier noise on * changes (more exclusive classes generally means a *lower* natural
* out-of-vocabulary input rather than a real, confident verdict. The * confidence ceiling, softmax mass spread thinner).
* clauses this threshold exists to actually trust score far higher in *
* practice (`0.91``1.0` for the real corrected cases found this session) * Currently `0.25`, set against the corpus as expanded to ~74 techniques
* `0.75` sits comfortably above the noise floor and below every genuine * (`services/tech-step-intent-service/intent_service/training_data.py`,
* match seen so far. * `_TRAINING_ITERATIONS = 25`, `textcat` trained on each technique's own
* `synonyms` in addition to its `utterances` see that constant's own
* comment for the calibration history) from manual spot-checks, not yet a
* real `calibrate-tech-step-threshold.ts` sweep against
* `TECH_STEP_EVAL_DATASET` (needs Postgres see that script's own doc
* comment): observed real-case scores ranged `0.31`-`0.89` (`simmer`
* lowest, still correct in argmax and anchored anyway; `melt` highest, the
* motivating anchor-less case), against a noise floor around `0.02`
* (English text through the French classifier). `0.25` sits with real
* margin above the noise floor and below every real case seen so far, but
* **this is a placeholder pending the real eval-dataset sweep** do not
* treat it as load-bearing precision the way the original `0.45`
* (calibrated against the ~26-technique corpus, `TECH_STEP_EVAL_DATASET`
* F1 plateauing exactly there) was.
*/ */
const CONFIDENCE_THRESHOLD = 0.75; export const CONFIDENCE_THRESHOLD = 0.25;
/** /**
* Trains and owns the `node-nlp` model behind {@link matchTechStepSpans} * One clause's full classification detail the finer-grained sibling of
* a real class (not a plain object of functions) per this repo's * {@link TechStepMatch}, exposing the raw intent/score
* service-style-logic convention, even though it's only ever used as the * `TechStepClassifierService`'s private `_classifyClause` normally
* one shared {@link techStepClassifier} singleton below: it holds real * collapses into a single accepted-or-fallback verdict. Nothing on the
* state (the trained model, the memoized training/lookup promises), not * interactive save/read path needs this (that's exactly what
* just grouped stateless helpers. * `_classifyClause`'s threshold + fallback logic is for) it exists for
* `services/tech-step-llm-worker`'s "audit low-confidence clauses" job
* (`modules/internal/tech-step-worker.service.ts`'s `getAuditBatch`), which
* needs to see *which* clauses the classifier itself wasn't sure about, not
* just its final best-effort verdict.
*/ */
export class TechStepClassifierService { export interface TechStepClauseClassification {
/** node-nlp's manager — both NER (enum entities) and NLP (intent classification) live on the same instance, trained together. */ /** The clause's own text (`description.slice(start, end)`, trimmed). */
private readonly _manager: NlpManager; clauseText: string;
/** Memoized training pass — `undefined` until the first call starts it, after which every caller (concurrent or not) awaits the same promise rather than retraining. */ start: number;
private _trained: Promise<void> | undefined; end: number;
/** Memoized `TechStep.key -> id` lookup — training data only knows techniques by their stable `uid`/`key`, resolved to the real DB id once, alongside training. */ /** The clause's NER anchor's own implied technique `uid`, if it had one — same as `TechStepClause.anchor.uid`. */
private _techStepIdByUid: Map<string, number> | undefined; anchorUid: string | null;
/** The intent classifier's own top guess for this clause, whatever its score — `null` only when the intent service had nothing trained for `locale`, or the clause text was blank. Unlike {@link TechStepMatch}, never silently replaced by the anchor's uid — the whole point of this type is to expose the classifier's raw opinion, confident or not. */
public constructor() { intentUid: string | null;
this._manager = new NlpManager({ /** The intent classifier's own confidence for `intentUid` — `0` when `intentUid` is `null` (nothing to have a score about). */
languages: ["fr", "en"], score: number;
forceNER: true,
nlu: { log: false },
// node-nlp's enum-entity NER defaults to a fuzzy (Levenshtein-based)
// 0.8 accuracy threshold — loose enough that e.g. "faire" (the
// generic French helper verb in almost every recipe step) fuzzy-
// matches `fry`'s synonym "frire" at 0.80, a false positive found
// while tuning this against the real training corpus. `1` (exact,
// after node-nlp's own case/accent/stemming normalization — real
// conjugation variance is still covered by listing each form in
// `tech-step-training-data.ts`) removed it without losing any real
// match. Precision matters more than recall for this stage — NER
// only proposes candidate split points, `_classifyClause`'s trained
// model (not fuzzy string distance) is what actually has to be
// right.
ner: { threshold: 1 },
// node-nlp defaults to `autoSave`/`autoLoad: true` — silently
// persisting the trained model to a `model.nlp` file in the process's
// cwd, and *loading from that file instead of retraining* the next
// time a manager is constructed, if the file already exists. Found
// this the hard way: a stray `model.nlp` appeared at the repo root
// after running this locally. That's the opposite of what this
// service wants — `TECH_STEP_TRAINING_DATA` in code is the single
// source of truth this always trains fresh from (see this file's own
// doc comment) — a stale on-disk model silently shadowing a
// corpus/threshold update would be a nasty, hard-to-notice class of
// bug. Both off; nothing here should ever touch disk.
autoSave: false,
autoLoad: false,
});
} }
/** /**
* Forces training plus node-nlp's own one-time lazy setup (loading its * Owns the `TechStep.key -> id` lookup behind {@link matchTechStepSpans}
* bundled per-language stemmers/tokenizers on the *first* real * a real class (not a plain object of functions) per this repo's
* `NlpManager.process()` call takes a few seconds by itself, separate * service-style-logic convention, even though it's only ever used as the
* from and much slower than the ~40ms `train()` pass measured against * one shared {@link techStepClassifier} singleton below: it holds real
* this corpus while tuning the pipeline) to happen now, synchronously * state (the memoized lookup promise), not just grouped stateless helpers.
* with server startup (see `server.ts`), rather than stalling whichever * The actual NER/intent-classification model lives entirely in
* request happens to be first to save/preview a recipe. * `services/tech-step-intent-service` (a separate process, trained from
* its own `training_data.py` at its own startup) this class never
* trains or pushes anything to it, it only calls `POST /v1/process` and
* resolves whatever `uid` comes back to a local DB id.
*/
export class TechStepClassifierService {
/** Memoized `TechStep.key -> id` lookup — resolved from the DB once, reused by every call rather than queried per request. `undefined` until the first call starts loading it, after which every caller (concurrent or not) awaits the same promise. */
private _techStepIdsLoaded: Promise<void> | undefined;
private _techStepIdByUid: Map<string, number> | undefined;
/** Same memoized-lookup shape as {@link _techStepIdsLoaded}/{@link _techStepIdByUid}, for `Utensil.key -> id` instead — a `kind: "utensil"` entity from the intent service resolves through this map, never `_techStepIdByUid`. */
private _utensilIdsLoaded: Promise<void> | undefined;
private _utensilIdByUid: Map<string, number> | undefined;
/**
* Forces the `TechStep.key -> id` lookup to load now, synchronously with
* server startup (see `server.ts`, which also retries this against a
* not-yet-reachable intent service), rather than stalling whichever
* request happens to be first to save/preview a recipe. Doesn't wait on
* `services/tech-step-intent-service` finishing its own training that
* service is only ever considered "up" by Docker Compose/CI once it
* already is (see that service's `GET /health`), so by the time this
* runs in a real deployment it's already trained; a request racing an
* intent service that's genuinely still starting just gets an empty
* match list back (see `IntentServiceProcessResult`'s own doc comment),
* not an error.
*/ */
public async warmUp(): Promise<void> { public async warmUp(): Promise<void> {
try { try {
@ -335,26 +379,35 @@ export class TechStepClassifierService {
*/ */
public async matchTechStepSpans(description: string, locale: string): Promise<TechStepMatch[]> { public async matchTechStepSpans(description: string, locale: string): Promise<TechStepMatch[]> {
try { try {
await this._ensureTrained(); await Promise.all([this._ensureTechStepIdsLoaded(), this._ensureUtensilIdsLoaded()]);
if (description.trim().length === 0) return []; if (description.trim().length === 0) return [];
const nerResult = await this._manager.process(locale, description); // Loaded fresh per call (once per step, see `recipe.service.ts`'s
// `matchStepsTechSteps`) rather than memoized like the id lookups
// above — same "cheap enough, and reference data can change between
// calls without a restart" posture `loadIngredientCatalog`/
// `loadUnitCatalog`'s own doc comments already describe for their
// other callers (`ingredient-matcher.ts`, `sources.service.ts`).
const [ingredientCatalog, unitCatalog] = await Promise.all([
loadIngredientCatalog(locale),
loadUnitCatalog(locale),
]);
// The intent service returns two kinds of candidate (see `kind` on
// `IntentServiceEntity`): technique mentions (its corpus-trained
// `PhraseMatcher`) and utensil mentions (its static one, see
// `utensil_vocabulary.py`). Only the former ever anchor a clause —
// `splitIntoClauses` cuts a description around *techniques*, a
// mentioned utensil doesn't introduce a clause boundary of its own,
// it just gets attributed to whichever clause its span falls inside
// (see the loop below). Its `start`/`end` are already `[start, end)`
// (matching `String.prototype.slice`), unlike node-nlp's inclusive
// `end` — no `+ 1` needed either.
const nerResult = await intentServiceClient.process(locale, description);
const candidates: TechniqueCandidate[] = nerResult.entities const candidates: TechniqueCandidate[] = nerResult.entities
// node-nlp's language plugins also auto-extract their own built-in .filter((entity) => entity.kind === "technique")
// entities (numbers, durations, dates…) alongside the enum .map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
// entities `_train` registered from `TECH_STEP_TRAINING_DATA` — const utensilEntities = nerResult.entities.filter((entity) => entity.kind === "utensil");
// `type === "enum"` is what tells the two apart; without this
// filter a step like "10 minutes" would hand `splitIntoClauses` a
// bogus "duration" candidate that resolves to no real technique.
.filter((entity) => entity.type === "enum")
.map((entity) => ({
uid: entity.entity,
start: entity.start,
// node-nlp's own `end` is inclusive (verified against a real
// trained model) — `+ 1` converts to this module's `[start, end)`
// convention, matching `String.prototype.slice`.
end: entity.end + 1,
}));
const clauses = splitIntoClauses(description, candidates); const clauses = splitIntoClauses(description, candidates);
const matches: TechStepMatch[] = []; const matches: TechStepMatch[] = [];
@ -368,12 +421,35 @@ export class TechStepClassifierService {
// persist a dangling id. // persist a dangling id.
if (techStepId === undefined) continue; if (techStepId === undefined) continue;
const span = clause.anchor ?? { start: clause.start, end: clause.end }; const span = clause.anchor ?? { start: clause.start, end: clause.end };
const ingredients = findIngredientMentions(
description.slice(clause.start, clause.end),
ingredientCatalog,
unitCatalog,
locale,
).map((mention) => ({
...mention,
start: mention.start + clause.start,
end: mention.end + clause.start,
}));
const utensils: UtensilMention[] = utensilEntities.flatMap((entity) => {
if (entity.start < clause.start || entity.end > clause.end) return [];
const utensilId = this._utensilIdByUid?.get(entity.uid);
// Same drift guard as `techStepId` above.
return utensilId === undefined
? []
: [{ utensilId, start: entity.start, end: entity.end }];
});
matches.push({ matches.push({
techStepId, techStepId,
start: span.start, start: span.start,
end: span.end, end: span.end,
contextStart: clause.start, contextStart: clause.start,
contextEnd: clause.end, contextEnd: clause.end,
ingredients,
utensils,
}); });
} }
@ -388,6 +464,61 @@ export class TechStepClassifierService {
} }
} }
/**
* Splits `description` into clauses exactly like {@link matchTechStepSpans}
* does, but returns each clause's *raw* classification detail
* ({@link TechStepClauseClassification}) instead of the threshold-applied,
* anchor-fallback-resolved `TechStepMatch` see that type's doc comment
* for why/who needs this. Deliberately a separate traversal rather than a
* shared refactor with `matchTechStepSpans`/`_classifyClause`: this method
* exists purely to add a new, additive read path without risking a
* behavior change to the two already-relied-on methods above.
*/
public async classifyClauses(
description: string,
locale: string,
): Promise<TechStepClauseClassification[]> {
try {
await this._ensureTechStepIdsLoaded();
if (description.trim().length === 0) return [];
const nerResult = await intentServiceClient.process(locale, description);
const candidates: TechniqueCandidate[] = nerResult.entities
.filter((entity) => entity.kind === "technique")
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
const clauses = splitIntoClauses(description, candidates);
const results: TechStepClauseClassification[] = [];
for (const clause of clauses) {
const clauseText = description.slice(clause.start, clause.end).trim();
const anchorUid = clause.anchor?.uid ?? null;
if (clauseText.length === 0) {
results.push({
clauseText,
start: clause.start,
end: clause.end,
anchorUid,
intentUid: null,
score: 0,
});
continue;
}
const result = await intentServiceClient.process(locale, clauseText);
results.push({
clauseText,
start: clause.start,
end: clause.end,
anchorUid,
intentUid: result.intent,
score: result.intent === null ? 0 : result.score,
});
}
return results;
} catch (err) {
throw err; // see matchTechStepSpans()'s catch comment above
}
}
/** /**
* Convenience wrapper around {@link matchTechStepSpans} for callers that * Convenience wrapper around {@link matchTechStepSpans} for callers that
* only care about *which* techniques matched, not where e.g. * only care about *which* techniques matched, not where e.g.
@ -421,8 +552,8 @@ export class TechStepClassifierService {
const clauseText = description.slice(clause.start, clause.end).trim(); const clauseText = description.slice(clause.start, clause.end).trim();
if (clauseText.length === 0) return clause.anchor?.uid ?? null; if (clauseText.length === 0) return clause.anchor?.uid ?? null;
const result = await this._manager.process(locale, clauseText); const result = await intentServiceClient.process(locale, clauseText);
if (result.intent !== "None" && result.score >= CONFIDENCE_THRESHOLD) { if (result.intent !== null && result.score >= CONFIDENCE_THRESHOLD) {
return result.intent; return result.intent;
} }
return clause.anchor?.uid ?? null; return clause.anchor?.uid ?? null;
@ -432,52 +563,56 @@ export class TechStepClassifierService {
} }
/** /**
* Trains `_manager` from {@link TECH_STEP_TRAINING_DATA} and resolves the * Resolves the `uid -> TechStep.id` lookup exactly once memoized on
* `uid -> TechStep.id` lookup, both exactly once memoized on * `_techStepIdsLoaded` so a burst of concurrent calls (several steps of
* `_trained` so a burst of concurrent calls (several steps of the same * the same recipe save, awaited via the same event loop tick) all await
* recipe save, awaited via the same event loop tick) all await the one * the one in-flight DB query rather than each firing their own.
* in-flight training pass rather than each kicking off their own.
*/ */
private async _ensureTrained(): Promise<void> { private async _ensureTechStepIdsLoaded(): Promise<void> {
if (this._trained === undefined) { if (this._techStepIdsLoaded === undefined) {
this._trained = this._train(); this._techStepIdsLoaded = this._loadTechStepIds();
} }
try { try {
await this._trained; await this._techStepIdsLoaded;
} catch (err) { } catch (err) {
// A failed training pass must be retried by the *next* call, not // A failed load must be retried by the *next* call, not leave every
// leave every future call permanently rejecting against a stale // future call permanently rejecting against a stale failed promise.
// failed promise. this._techStepIdsLoaded = undefined;
this._trained = undefined;
throw err; throw err;
} }
} }
private async _train(): Promise<void> { private async _loadTechStepIds(): Promise<void> {
try { try {
const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } }); const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
this._techStepIdByUid = new Map(techSteps.map((techStep) => [techStep.key, techStep.id])); this._techStepIdByUid = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
} catch (err) {
for (const entry of TECH_STEP_TRAINING_DATA) { throw err; // see matchTechStepSpans()'s catch comment above
for (const [locale, data] of [
["fr", entry.fr],
["en", entry.en],
] as const) {
if (data.synonyms.length > 0) {
this._manager.addNamedEntityText(entry.uid, entry.uid, [locale], data.synonyms);
}
for (const utterance of data.utterances) {
this._manager.addDocument(locale, utterance, entry.uid);
}
} }
} }
await this._manager.train(); /** `Utensil.key -> id` counterpart of {@link _ensureTechStepIdsLoaded} — same memoize-once-retry-on-failure shape. */
private async _ensureUtensilIdsLoaded(): Promise<void> {
if (this._utensilIdsLoaded === undefined) {
this._utensilIdsLoaded = this._loadUtensilIds();
}
try {
await this._utensilIdsLoaded;
} catch (err) {
this._utensilIdsLoaded = undefined;
throw err;
}
}
private async _loadUtensilIds(): Promise<void> {
try {
const utensils = await prisma.utensil.findMany({ select: { id: true, key: true } });
this._utensilIdByUid = new Map(utensils.map((utensil) => [utensil.key, utensil.id]));
} catch (err) { } catch (err) {
throw err; // see matchTechStepSpans()'s catch comment above throw err; // see matchTechStepSpans()'s catch comment above
} }
} }
} }
/** Single shared instance — training is expensive enough (a few hundred ms) that every caller must reuse the one already-trained model, never spin up their own. */ /** Single shared instance — every caller reuses the one memoized `TechStep.key -> id` lookup rather than re-querying the DB. The actual model training (expensive — a couple of minutes, both locales combined) happens entirely inside `services/tech-step-intent-service`'s own startup, not here — see that service's `_TRAINING_ITERATIONS`. */
export const techStepClassifier = new TechStepClassifierService(); export const techStepClassifier = new TechStepClassifierService();

View file

@ -1,911 +0,0 @@
/**
* Training corpus for {@link TechStepClassifierService} (`tech-step-matcher.ts`)
* one entry per `TechStep` (`uid` matches `reference-seed-data.ts`'s
* `TECH_STEPS`, which still owns the reference `TechStep` rows themselves;
* this file replaces `TECH_STEPS[].mappings`' regex expressions as the
* *matching* data source).
*
* Two distinct kinds of content per technique/locale, feeding two distinct
* mechanisms of the classifier (see that file's doc comment for why both
* are needed):
*
* - `synonyms` short literal words/set phrases, fed to node-nlp's NER
* (enum entities). Mechanically equivalent to the old regexes' verb-form
* alternations, just spelled out as plain words instead of a pattern
* (node-nlp's own stemmer/fuzzy matching already covers minor
* conjugation/typo variance that the regexes had to enumerate by hand).
* Used only to find *candidate* technique mentions and cut a step into
* clauses around them never the final answer on their own.
* - `utterances` full example clauses, fed to node-nlp's NLP Manager as
* training documents for the intent classifier. Deliberately mixes
* keyword-anchored phrasings (reinforces the obvious case) with
* paraphrases that never use the technique's own verb at all (e.g.
* "jusqu'à ce que le beurre ait disparu" for `melt`) this second kind
* is what actually delivers on "comprendre le sens, pas juste les mots
* clés" (see the PR this file was introduced in): a clause reaching the
* classifier gets labeled by what it's trained to recognize as *meaning*
* this technique, not by which literal word triggered its extraction.
*
* Kept as static in-code data (not DB rows, unlike the old
* `TechStepMapping` table) because nothing needs to query/edit it at
* runtime it only ever feeds one thing, the classifier's one-time
* training pass (see `TechStepClassifierService._ensureTrained`) same
* reasoning `INGREDIENT_LABELS_EN` (`packages/shared`) is a plain object,
* not a database table.
*/
/** One technique's matching data for one locale — see this file's doc comment for what each list feeds. */
export interface TechStepLocaleTrainingData {
synonyms: string[];
utterances: string[];
}
/** One technique's full training entry — `uid` must match a `TECH_STEPS[].uid` in `reference-seed-data.ts`. */
export interface TechStepTrainingEntry {
uid: string;
fr: TechStepLocaleTrainingData;
en: TechStepLocaleTrainingData;
}
export const TECH_STEP_TRAINING_DATA: TechStepTrainingEntry[] = [
{
uid: "cook",
fr: {
synonyms: [
"cuire",
"cuisez",
"cuisant",
"cuisson",
"cuit",
"cuite",
"cuites",
"cuits",
"cuisiner",
"cuisinez",
"cuisiné",
"cuisinée",
"faire cuire",
"laisser cuire",
],
utterances: [
"faire cuire à feu moyen",
"laisser cuire jusqu'à ce que ce soit prêt",
"la cuisson dure environ dix minutes",
"jusqu'à ce que la viande ne soit plus rose au centre",
"poursuivre la cuisson à couvert",
// Two real recipe clauses found misclassified (as `preheat` and
// `panFry` respectively, both above the confidence threshold) once
// real, longer, comma-heavy sentences started reaching the
// classifier — neither error came from a missing keyword (both
// clauses' own NER anchor, "laisser cuire"/"faire cuire", was
// already right), just the classifier's low-heat/occasional-
// stirring phrasing not resembling anything short and clean-cut it
// had actually been trained on.
"baisser le feu et laisser cuire à découvert encore un quart d'heure",
"faire cuire à feu doux en remuant de temps en temps",
],
},
en: {
// NOT "cooked through"/"cooking through" — both are word-prefix
// extensions of "cooked"/"cooking" above, so any text containing them
// matches BOTH the short and long form as separate overlapping NER
// candidates, corrupting clause-splitting (confirmed via "It should
// be cooking through evenly", which spuriously grew a second,
// wrongly-classified `roast` candidate). See this pattern flagged
// throughout the file wherever it was found — the fix is always to
// drop the longer, redundant form rather than keep both.
synonyms: ["cook", "cooks", "cooked", "cooking"],
utterances: [
"cook over medium heat",
"cook until done",
"cooking takes about ten minutes",
"until no longer pink in the middle",
"continue cooking covered",
],
},
},
{
uid: "fry",
fr: {
synonyms: [
"frire",
"frit",
"frite",
"frites",
"friture",
"faire frire",
"faites frire",
"bain de friture",
"huile de friture",
],
utterances: [
"faire frire dans l'huile chaude",
"plonger dans la friture",
"jusqu'à ce que ce soit doré et croustillant à l'extérieur",
"l'huile doit être bien chaude avant d'y plonger les morceaux",
],
},
en: {
// NOT "frying oil" — a word-prefix extension of "frying" above (see
// the `cook` entry's comment for why that duplicates/corrupts NER
// candidates; here it was even worse, misclassifying as `preheat`).
synonyms: ["fry", "fries", "fried", "frying", "deep fry", "deep-fried", "deep frying"],
utterances: [
"fry in hot oil",
"deep fry until golden",
"until crisp and golden on the outside",
"the oil should be very hot before adding the pieces",
],
},
},
{
uid: "melt",
fr: {
synonyms: [
"fondre",
"fondu",
"fondue",
"fondues",
"faire fondre",
"faites fondre",
// Also a plausible way to say "melt" (heating something — usually
// a fat — until it liquefies), not just a `preheat` phrasing —
// restores what the regex-based system anchored on before this
// pipeline replaced it.
"faire chauffer",
"faites chauffer",
"liquéfier",
"liquéfiez",
"liquéfié",
"faire liquéfier",
],
utterances: [
"faire fondre le beurre",
"jusqu'à ce que le beurre ait disparu dans la poêle",
"le beurre doit être complètement liquide",
"laisser le fromage devenir tout liquide sur feu doux",
],
},
en: {
synonyms: ["melt", "melts", "melted", "melting", "liquefy", "liquefied"],
utterances: [
"melt the butter",
"until the butter has completely disappeared into the pan",
"the butter should be fully liquid",
"let the cheese turn completely liquid over low heat",
],
},
},
{
uid: "deglaze",
fr: {
// NOT "déglacer la poêle"/"déglacer le fond de cuisson" — both are
// word-prefix extensions of "déglacer" above (see `cook`'s comment
// for why that duplicates NER candidates).
synonyms: ["déglacer", "déglacez", "déglacé", "déglacée", "déglaçage"],
utterances: [
"déglacer avec le vin blanc",
"verser le vin dans la poêle chaude pour décoller les sucs",
"gratter les sucs de cuisson au fond de la casserole avec un peu de bouillon",
],
},
en: {
// NOT "deglaze the pan" — a word-prefix extension of "deglaze" above
// (see `cook`'s comment for why that duplicates NER candidates).
synonyms: ["deglaze", "deglazes", "deglazed", "deglazing", "lift the browned bits"],
utterances: [
"deglaze with white wine",
"pour the wine into the hot pan to lift the browned bits",
"scrape up the browned bits at the bottom of the pan with a splash of stock",
],
},
},
{
uid: "simmer",
fr: {
synonyms: [
"mijoter",
"mijotez",
"mijote",
"mijotant",
"mijoté",
"frémir",
"frémissant",
"frémissante",
"à petit feu",
],
utterances: [
"laisser mijoter à feu doux",
"faire mijoter pendant une heure",
"de petites bulles doivent remonter doucement à la surface",
"laisser cuire tout doucement à couvert pendant longtemps",
],
},
en: {
// NOT "simmering gently" — a word-prefix extension of "simmering"
// above (see `cook`'s comment for why that duplicates NER candidates).
synonyms: ["simmer", "simmers", "simmered", "simmering", "gentle simmer", "low simmer"],
utterances: [
"let it simmer over low heat",
"simmer for one hour",
"small bubbles should gently rise to the surface",
"let it cook very gently, covered, for a long time",
],
},
},
{
uid: "boil",
fr: {
synonyms: [
"bouillir",
"bouillant",
"bouillie",
"bouillies",
"ébullition",
"porter à ébullition",
"gros bouillons",
],
utterances: [
"porter à ébullition",
"faire bouillir l'eau",
"de grosses bulles doivent agiter la surface avec force",
"jusqu'à ce que ça bouillonne franchement",
],
},
en: {
// NOT "boiling point" — a word-prefix extension of "boiling" above
// (see `cook`'s comment for why that duplicates NER candidates).
synonyms: ["boil", "boils", "boiled", "boiling", "rolling boil"],
utterances: [
"bring to a boil",
"boil the water",
"large bubbles should be vigorously breaking the surface",
"until it's rolling vigorously",
],
},
},
{
uid: "roast",
fr: {
// NOT "rôti au four" — a word-prefix extension of "rôti" above (see
// `cook`'s comment for why that duplicates NER candidates).
synonyms: ["rôtir", "rôti", "rôtie", "rôties", "rôtis", "rôtissage"],
utterances: [
"faire rôtir la volaille entière",
"le rôti doit dorer uniformément de tous les côtés",
"cuire la pièce de viande entière au four à chaleur sèche",
],
},
en: {
synonyms: ["roast", "roasts", "roasted", "roasting", "oven-roast", "oven roasted"],
utterances: [
"roast the whole bird",
"it should brown evenly on every side",
"cook the whole piece of meat in dry oven heat",
],
},
},
{
uid: "grill",
fr: {
synonyms: [
"griller",
"grillez",
"grillé",
"grillée",
"grillées",
"grillade",
"grillades",
"barbecue",
"au barbecue",
],
utterances: [
"faire griller sur la grille du barbecue",
"marquer les steaks sur une plaque brûlante",
"des traces de quadrillage doivent apparaître à la cuisson",
],
},
en: {
synonyms: ["grill", "grills", "grilled", "grilling", "barbecue", "char-grill", "charbroiled"],
utterances: [
"grill on the barbecue rack",
"sear the steaks on a scorching-hot plate",
"char marks should appear as it cooks",
],
},
},
{
uid: "panFry",
fr: {
// Deliberately NOT "poêlé"/"poêlée"/"poêlés" here, despite reading
// like natural panFry vocabulary: node-nlp's French stemmer reduces
// them to the same root as the bare noun "poêle" (a pan), so
// registering them made every plain mention of "poêle" — e.g.
// `preheat`'s own "la poêle" — a false-positive panFry candidate too.
// Found via the "jusqu'à ce que le beurre ait disparu dans la poêle"
// regression test, which unexpectedly grew a spurious panFry match.
synonyms: ["sauter", "sautez", "sauté", "sautée", "sautées", "sautant", "à la poêle"],
utterances: [
"faire sauter les légumes à la poêle",
"saisir rapidement à feu vif en remuant sans cesse",
"faire revenir en remuant vivement dans une poêle très chaude",
],
},
en: {
synonyms: [
"sauté",
"sauteed",
"sautéed",
"sauteing",
"pan-fry",
"pan fried",
"pan-fried",
"stir-fry",
"pan searing",
"seared in a pan",
],
utterances: [
"sauté the vegetables in a pan",
"quickly sear over high heat, stirring constantly",
"cook briskly, stirring, in a very hot pan",
],
},
},
{
uid: "blanch",
fr: {
synonyms: ["blanchir", "blanchissez", "blanchi", "blanchie", "blanchies", "blanchiment"],
utterances: [
"faire blanchir les légumes deux minutes dans l'eau bouillante",
"plonger brièvement dans l'eau bouillante puis directement dans l'eau glacée",
"cuire très rapidement à l'eau bouillante avant de stopper la cuisson au froid",
],
},
en: {
// "parboil" is folded in here rather than kept a separate technique —
// in home-cooking usage (as opposed to professional usage, where they
// can differ) it names the same "briefly pre-cook in boiling water"
// move blanching does.
synonyms: [
"blanch",
"blanches",
"blanched",
"blanching",
"parboil",
"parboiled",
"parboiling",
],
utterances: [
"blanch the vegetables for two minutes in boiling water",
"briefly plunge into boiling water then straight into ice water",
"cook very quickly in boiling water before stopping it cold",
],
},
},
{
uid: "marinate",
fr: {
synonyms: [
"mariner",
"marinez",
"mariné",
"marinée",
"marinées",
"marinade",
"macérer",
"macérez",
"macération",
"faire mariner",
],
utterances: [
"laisser mariner la viande toute la nuit au réfrigérateur",
"faire tremper dans la sauce plusieurs heures avant cuisson pour parfumer",
"laisser reposer dans le mélange d'huile et d'épices avant de cuisiner",
],
},
en: {
// NOT "marinating for" — a word-prefix extension of "marinating"
// above (see `cook`'s comment for why that duplicates NER candidates
// — here it was even worse, misclassifying as `simmer`).
synonyms: [
"marinate",
"marinates",
"marinated",
"marinating",
"marinade",
"soak in the marinade",
],
utterances: [
"let the meat marinate overnight in the fridge",
"soak in the sauce for several hours before cooking to flavor it",
"let it sit in the oil and spice mixture before cooking",
],
},
},
{
uid: "chop",
fr: {
// NOT "hacher grossièrement" — a word-prefix extension of "hacher"
// above (see `cook`'s comment for why that duplicates NER candidates).
synonyms: [
"hacher",
"hachez",
"haché",
"hachée",
"hachées",
"hachis",
"couper en morceaux",
"tailler en morceaux",
],
utterances: [
"hacher finement les oignons",
"couper en tout petits morceaux irréguliers au couteau",
"réduire les herbes en petits fragments avant de les ajouter",
],
},
en: {
// NOT "chop coarsely" — a word-prefix extension of "chop" above (see
// `cook`'s comment for why that duplicates NER candidates).
synonyms: ["chop", "chops", "chopped", "chopping", "roughly chop", "coarsely chopped"],
utterances: [
"finely chop the onions",
"cut into small, uneven pieces with a knife",
"break the herbs down into small bits before adding them",
],
},
},
{
uid: "peel",
fr: {
synonyms: [
"éplucher",
"épluchez",
"épluché",
"épluchée",
"épluchées",
"épluchage",
"peler",
"pelez",
"pelé",
"pelée",
"pelées",
],
utterances: [
"éplucher les pommes de terre",
"retirer la peau des carottes avec un économe",
"ôter la pelure du fruit avant de le couper",
],
},
en: {
synonyms: ["peel", "peels", "peeled", "peeling", "pare", "pared", "paring"],
utterances: [
"peel the potatoes",
"remove the skin from the carrots with a peeler",
"take the skin off the fruit before cutting it",
],
},
},
{
uid: "mince",
fr: {
synonyms: [
"émincer",
"émincez",
"émincé",
"émincée",
"émincées",
"ciseler",
"ciselez",
"ciselé",
"ciselée",
"ciselées",
],
utterances: [
"émincer l'oignon en fines lamelles",
"couper en très fines tranches régulières",
"détailler en lamelles aussi fines que possible",
// Without this, a short clause naming a different vegetable —
// "Émincer les tomates" — scored just above `melt`'s confidence
// threshold instead (a training-set-composition side effect of
// adding utterances elsewhere in this same pass, found by the full
// regression suite). A second example anchored on a different noun
// widens `mince`'s own region enough to reclaim it.
"émincer les tomates en fines rondelles",
],
},
en: {
// NOT "mince finely" — a word-prefix extension of "mince" above (see
// `cook`'s comment for why that duplicates NER candidates).
synonyms: ["mince", "minces", "minced", "mincing", "thinly slice", "finely mince"],
utterances: [
"mince the onion into thin strips",
"cut into very thin, even slices",
"slice into strips as thin as possible",
],
},
},
{
uid: "mix",
fr: {
synonyms: [
"mélanger",
"mélangez",
"mélangé",
"mélangée",
"mélangées",
"mélange",
"brasser",
"brassez",
"amalgamer",
"amalgamez",
],
utterances: [
"mélanger tous les ingrédients dans un saladier",
"combiner le sucre et la farine ensemble",
"remuer jusqu'à obtenir une préparation homogène",
],
},
en: {
synonyms: [
"mix",
"mixes",
"mixed",
"mixing",
"combine",
"combined",
"blend",
"blended",
"blending",
"stir together",
],
utterances: [
"mix all the ingredients in a bowl",
"combine the sugar and flour together",
"stir until the mixture is smooth and even",
],
},
},
{
uid: "whisk",
fr: {
synonyms: [
"fouetter",
"fouettez",
"fouetté",
"fouettée",
"fouettées",
"au fouet",
"battre au fouet",
"monter au fouet",
],
utterances: [
"fouetter les œufs et le sucre",
"battre vigoureusement au fouet jusqu'à ce que ça blanchisse",
"travailler énergiquement pour incorporer de l'air au mélange",
// Without these, "Fouetter les blancs en neige" misclassified as
// `foldIn` — its own training utterance below also happens to say
// "les blancs en neige", and node-nlp's intent classifier leaned on
// that shared noun phrase over the actual verb. The exact phrase
// itself is needed (not just a paraphrase of it) — a longer,
// differently-worded utterance alone wasn't enough to outweigh
// `foldIn`'s own close phrasing.
"fouetter les blancs en neige",
"fouetter les blancs en neige jusqu'à ce qu'ils soient fermes",
],
},
en: {
synonyms: ["whisk", "whisks", "whisked", "whisking", "beat", "whip", "whipped", "whipping"],
utterances: [
"whisk the eggs and sugar",
"beat vigorously with a whisk until pale",
"work it briskly to whip air into the mixture",
"whisk the egg whites until stiff peaks form",
],
},
},
{
uid: "foldIn",
fr: {
synonyms: [
"incorporer",
"incorporez",
"incorporé",
"incorporée",
"incorporées",
// NOT "incorporer délicatement" — it's a superstring of "incorporer"
// above, so both would match the same text and hand
// `splitIntoClauses` two overlapping candidates for one mention
// (found via "Incorporer délicatement la farine" producing two
// duplicate matches instead of one).
"mélanger délicatement",
],
utterances: [
"incorporer délicatement les blancs en neige",
"ajouter en soulevant doucement la masse pour ne pas casser les bulles",
"mélanger tout doucement de bas en haut pour garder l'air emprisonné",
],
},
en: {
synonyms: ["fold in", "folds in", "folded in", "folding in", "gently fold", "fold gently"],
utterances: [
"gently fold in the beaten egg whites",
"add by gently lifting the batter so you don't knock the air out",
"very gently stir from the bottom up to keep the air trapped in",
],
},
},
{
uid: "setAside",
fr: {
synonyms: [
"réserver",
"réservez",
"réservé",
"réservée",
"réservées",
"mettre de côté",
"laisser de côté",
],
utterances: [
"réserver au frais en attendant",
"mettre de côté pour plus tard",
"laisser attendre sur le plan de travail pendant la préparation du reste",
],
},
en: {
synonyms: ["set aside", "sets aside", "setting aside", "set it aside", "reserve", "reserved"],
utterances: [
"set aside in the fridge for now",
"put it aside for later",
"let it wait on the counter while you prepare the rest",
],
},
},
{
uid: "season",
fr: {
synonyms: [
"assaisonner",
"assaisonnez",
"assaisonné",
"assaisonnée",
"assaisonnement",
"relever",
"relevez",
"épicer",
"épicez",
],
utterances: [
"assaisonner avec du sel et du poivre",
"rectifier le goût en ajoutant des épices",
"ajouter du sel selon votre goût avant de servir",
],
},
en: {
synonyms: ["season", "seasons", "seasoned", "seasoning", "spice it up", "add seasoning"],
utterances: [
"season with salt and pepper",
"adjust the taste by adding spices",
"add salt to taste before serving",
],
},
},
{
uid: "drain",
fr: {
synonyms: [
"égoutter",
"égouttez",
"égoutté",
"égouttée",
"égouttées",
"essorer",
"essorez",
"essoré",
"essorée",
],
utterances: [
"égoutter les pâtes dans une passoire",
"verser dans une passoire pour retirer l'eau de cuisson",
"laisser l'excédent d'eau s'écouler avant de servir",
],
},
en: {
synonyms: ["drain", "drains", "drained", "draining", "strain", "strained", "straining"],
utterances: [
"drain the pasta in a colander",
"pour into a colander to remove the cooking water",
"let the excess water run off before serving",
],
},
},
{
uid: "brown",
fr: {
synonyms: [
"faire revenir",
"faites revenir",
"faire dorer",
"faites dorer",
"colorer",
"colorez",
"faire colorer",
],
utterances: [
"faire revenir les oignons dans l'huile chaude",
"faire dorer la viande sur toutes les faces",
"saisir jusqu'à ce que la surface prenne une belle couleur caramel",
],
},
en: {
// Verb forms only (not bare "brown"), same reasoning the old regex
// doc comment gave — a bare "brown" false-positives on ingredient
// descriptions like "brown sugar"/"brown rice", which never get to
// the classifier since they're not step text, but keeping the
// synonym itself anchored costs nothing and stays consistent.
synonyms: ["browned", "browning"],
utterances: [
"brown the onions in hot oil",
"brown the meat on every side",
"sear until the surface turns a deep caramel color",
],
},
},
{
uid: "rest",
fr: {
synonyms: ["reposer", "laisser reposer", "laissez reposer", "temps de repos"],
utterances: [
"laisser reposer la pâte trente minutes",
"laisser la viande se détendre hors du four avant de la découper",
"attendre quelques minutes avant de servir pour que les jus se répartissent",
],
},
en: {
// Anchored to "let ... rest"/"rest for" rather than bare "rest",
// same false-positive reasoning as `brown` above ("the rest of the").
synonyms: ["let it rest", "let them rest", "resting for", "rested for", "resting time"],
utterances: [
"let the dough rest for thirty minutes",
"let the meat relax outside the oven before carving it",
"wait a few minutes before serving so the juices redistribute",
],
},
},
{
uid: "preheat",
fr: {
synonyms: [
"préchauffer",
"préchauffez",
"préchauffé",
"préchauffée",
// A pan already described as hot ("poêle chaude") implies it's
// been preheated, without the verb itself — the classic "Dans une
// poêle chaude, faire chauffer une noix de beurre" case (both
// `preheat` and `melt` in one instruction).
"poêle chaude",
"préchauffage",
],
utterances: [
"préchauffer le four à 180 degrés",
"mettre le four à chauffer avant d'y placer le plat",
"allumer le four à l'avance pour qu'il soit à température",
// A pan gets preheated too, not just an oven — without an example
// like this, "poêle" (which also appears throughout `panFry`'s own
// training utterances) biased the classifier toward `panFry` for
// any preheating clause that happens to mention a pan, found while
// testing against the classic "Préchauffer la poêle, puis faire
// fondre le beurre" case.
"préchauffer la poêle avant d'y verser l'huile",
"faire chauffer la poêle à vide quelques minutes",
// "poêle" + "feu vif" together still read as `panFry` (the act of
// actually cooking something in it) rather than `preheat` (getting
// it hot beforehand, nothing in it yet) without an example this
// close to that exact wording — found via "mettre la poêle sur feu
// vif" (no food mentioned at all) still classifying as panFry.
"mettre la poêle vide sur feu vif avant d'ajouter quoi que ce soit",
"mettre la poêle sur feu vif",
],
},
en: {
// NOT "preheating time" — a word-prefix extension of "preheating"
// above (see `cook`'s comment for why that duplicates NER candidates).
synonyms: ["preheat", "preheats", "preheated", "preheating", "hot pan"],
utterances: [
"preheat the oven to 180 degrees",
"turn the oven on to heat up before putting the dish in",
"switch the oven on ahead of time so it's up to temperature",
"preheat the pan before adding the oil",
"heat the empty pan for a few minutes first",
],
},
},
{
uid: "bake",
fr: {
synonyms: [
"cuire au four",
"cuisson au four",
"enfourner",
"enfournez",
"au four",
"enfourné",
"enfournée",
],
utterances: [
"enfourner pendant quarante-cinq minutes",
"mettre au four jusqu'à ce que ce soit doré",
"cuire dans le four préchauffé jusqu'à ce que la surface soit ferme",
],
},
en: {
// NOT "baked in the oven" — a word-prefix extension of "baked" above
// (see `cook`'s comment for why that duplicates NER candidates).
synonyms: ["bake", "bakes", "baked", "baking", "in the oven", "oven-baked"],
utterances: [
"bake for forty-five minutes",
"put it in the oven until golden",
"cook in the preheated oven until the surface is firm",
],
},
},
{
uid: "plate",
fr: {
// NOT "dressage de l'assiette" — a word-prefix extension of
// "dressage" above (see `cook`'s comment for why that duplicates NER
// candidates).
synonyms: ["dresser", "dressez", "dressage", "disposer dans l'assiette"],
utterances: [
"dresser harmonieusement dans les assiettes",
"disposer joliment sur l'assiette avant de servir",
"présenter avec soin au centre de l'assiette",
],
},
en: {
// NOT "plate up"/"plated nicely" — both are word-prefix extensions of
// "plate"/"plated" above (see `cook`'s comment for why that
// duplicates NER candidates).
synonyms: ["plate", "plates", "plated", "plating"],
utterances: [
"plate it up nicely",
"arrange it neatly on the plate before serving",
"present it carefully in the center of the plate",
],
},
},
{
uid: "coat",
fr: {
synonyms: [
"napper",
"nappez",
"nappé",
"nappée",
"nappées",
"nappage",
"enrober",
"enrobez",
"enrobé",
"enrobée",
"enrobées",
],
utterances: [
"napper le gâteau de chocolat fondu",
"recouvrir uniformément d'une fine couche de sauce",
"verser la sauce par-dessus pour bien enrober",
],
},
en: {
// NOT "coat evenly" — a word-prefix extension of "coat" above (see
// `cook`'s comment for why that duplicates NER candidates).
synonyms: ["coat", "coats", "coated", "coating", "dredge", "dredged", "dredging"],
utterances: [
"coat the cake with melted chocolate",
"cover evenly with a thin layer of sauce",
"pour the sauce over it so it's well covered",
],
},
},
];

View file

@ -0,0 +1,50 @@
import { timingSafeEqual } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode } from "@batch-cooking/shared";
import type { NextFunction, Request, Response } from "express";
import { env } from "../config/env.js";
/** Header `services/tech-step-llm-worker` sends its shared secret on. Not `Authorization`/a bearer scheme — this isn't a user session, just one internal caller authenticating to another, same "one flat shared secret" shape as e.g. a webhook signing header. */
const INTERNAL_WORKER_SECRET_HEADER = "x-internal-worker-secret";
/**
* Express middleware guarding `/internal/tech-steps/*` the surface
* `services/tech-step-llm-worker` (a process outside this monorepo, no
* Prisma access of its own, see that service's own README) reads
* low-confidence NLP clauses and pending `StepTechStepCorrection`s from,
* and posts `TechStepTrainingSuggestion`s back to. Never reachable by an
* end user's session cookie deliberately a *different* auth mechanism
* than {@link requireAuth} (`require-auth.ts`), not layered on top of it,
* since the worker has no `UserProfile`/session of its own to authenticate
* as.
*
* Fails closed: an unset `INTERNAL_WORKER_SECRET` (the default in any
* environment that doesn't run the worker, see `config/env.ts`) rejects
* every request rather than leaving the surface open, same posture as a
* misconfigured `JWT_SECRET` would if it had a working fallback.
*
* @throws {HttpError} `401 NOT_AUTHENTICATED` if the header is missing,
* wrong, or the server has no secret configured at all never
* distinguishes the reason, same posture as {@link requireAuth}.
*/
export function requireInternalWorker(req: Request, _res: Response, next: NextFunction): void {
const provided = req.header(INTERNAL_WORKER_SECRET_HEADER);
if (env.INTERNAL_WORKER_SECRET === undefined || provided === undefined) {
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
return;
}
// `timingSafeEqual` throws on mismatched buffer lengths rather than
// returning `false` — checked separately first. A length mismatch alone
// already means "not equal", so this loses no timing-attack protection
// (an attacker learns nothing beyond what a differing length itself
// already reveals, no different from `!==` on the common case where the
// secret's real length isn't a secret worth protecting).
const expected = Buffer.from(env.INTERNAL_WORKER_SECRET);
const actual = Buffer.from(provided);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
return;
}
next();
}

View file

@ -0,0 +1,49 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
auditBatchQuerySchema,
submitTrainingSuggestionsSchema,
workerBatchQuerySchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { requireInternalWorker } from "../../middlewares/require-internal-worker.js";
import {
getAuditBatch,
getPendingCorrections,
submitTrainingSuggestions,
} from "./tech-step-worker.service.js";
/**
* Router mounted at `/internal/tech-steps` in app.ts every route requires
* {@link requireInternalWorker}, never {@link requireAuth}
* (`middlewares/require-auth.ts`): this is `services/tech-step-llm-worker`
* authenticating as itself, not a user session. See that middleware's own
* doc comment for why the two are deliberately separate mechanisms.
*/
export const techStepWorkerRouter = Router();
techStepWorkerRouter.get(
"/audit-batch",
requireInternalWorker,
wrapAsyncHandler(async (req, res) => {
const input = auditBatchQuerySchema.parse(req.query);
res.status(200).json(await getAuditBatch(input.locale, input.limit));
}),
);
techStepWorkerRouter.get(
"/pending-corrections",
requireInternalWorker,
wrapAsyncHandler(async (req, res) => {
const input = workerBatchQuerySchema.parse(req.query);
res.status(200).json(await getPendingCorrections(input.limit));
}),
);
techStepWorkerRouter.post(
"/training-suggestions",
requireInternalWorker,
wrapAsyncHandler(async (req, res) => {
const input = submitTrainingSuggestionsSchema.parse(req.body);
res.status(201).json(await submitTrainingSuggestions(input));
}),
);

View file

@ -0,0 +1,201 @@
import { HttpError } from "@batch-cooking/error-tools";
import {
ErrorCode,
type PendingTechStepCorrectionView,
type SubmitTrainingSuggestionsInput,
type TechStepAuditClauseView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import {
CONFIDENCE_THRESHOLD,
techStepClassifier,
} from "../../lib/recipe-matching/tech-step-matcher.js";
/**
* Read/write surface `services/tech-step-llm-worker` calls through
* `/internal/tech-steps/*` (`tech-step-worker.routes.ts`, guarded by
* `requireInternalWorker`) the worker has no Prisma client or database
* credentials of its own (see that service's own README), so every
* corrections/audit-sample read and every suggestion write goes through
* here rather than the worker touching this schema directly. Keeps
* `apps/api` the single owner of the schema/migrations, and keeps the
* worker a pure "read some text, run inference, post a suggestion" process
* with nothing to keep in sync if the schema changes shape.
*/
/**
* How many of the most recently created `Step`s {@link getAuditBatch} scans
* per call before filtering down to low-confidence clauses a fixed
* recency-biased sample, not every `Step` in the database, to keep this
* endpoint's cost bounded regardless of how large the recipe catalog gets.
* Recently-added steps are also the steps most likely to still use
* vocabulary the training corpus hasn't caught up with yet, which is
* exactly what this audit is for. A smarter sampling strategy (e.g.
* weighted by how often a recipe is actually viewed/planned) is future
* work, not needed for this feature's first version.
*/
const AUDIT_SAMPLE_SIZE = 200;
/**
* Every low-confidence clause found across a recency-biased sample of
* existing `Step`s (see {@link AUDIT_SAMPLE_SIZE}), for
* `services/tech-step-llm-worker`'s `audit-low-confidence` job to get a
* second opinion on. "Low-confidence" mirrors exactly what
* `TechStepClassifierService._classifyClause` itself distrusts (a clause
* with an NER anchor but a classifier score under
* {@link CONFIDENCE_THRESHOLD}) the same clauses that pipeline already
* has to fall back to keyword-anchor guessing for, not an arbitrary
* separate cutoff.
*/
export async function getAuditBatch(
locale: string,
limit: number,
): Promise<TechStepAuditClauseView[]> {
try {
const steps = await prisma.step.findMany({
orderBy: { id: "desc" },
take: AUDIT_SAMPLE_SIZE,
select: { id: true, recipeId: true, description: true },
});
const results: TechStepAuditClauseView[] = [];
for (const step of steps) {
if (results.length >= limit) break;
const clauses = await techStepClassifier.classifyClauses(step.description, locale);
for (const clause of clauses) {
if (results.length >= limit) break;
const isLowConfidence = clause.anchorUid !== null && clause.score < CONFIDENCE_THRESHOLD;
if (!isLowConfidence) continue;
results.push({
stepId: step.id,
recipeId: step.recipeId,
clauseText: clause.clauseText,
anchorKey: clause.anchorUid,
intentKey: clause.intentUid,
score: clause.score,
locale,
});
}
}
return results;
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Every `StepTechStepCorrection` not yet turned into a
* `TechStepTrainingSuggestion` (`consumedAt IS NULL`), oldest first a
* FIFO queue the worker's `transform-corrections` job drains, `limit` at a
* time.
*
* `correctedTechStepId IS NOT NULL` on top of `consumedAt IS NULL`: a
* correction that *removes* a match ("no technique belongs here",
* `correctedTechStepId: null` see `StepTechStepCorrection`'s schema doc
* comment) has no technique to propose new positive training data *for*.
* Surfacing it here would leave it permanently unconsumable (the worker
* has nothing to submit a suggestion for, so it would never stamp
* `consumedAt`, and it would keep re-appearing in every future batch
* forever) excluded at the source instead, not filtered/skipped
* downstream by the worker.
*/
export async function getPendingCorrections(
limit: number,
): Promise<PendingTechStepCorrectionView[]> {
try {
const corrections = await prisma.stepTechStepCorrection.findMany({
where: { consumedAt: null, correctedTechStepId: { not: null } },
orderBy: { createdAt: "asc" },
take: limit,
include: {
step: { select: { id: true, recipeId: true, description: true } },
previousTechStep: { select: { key: true } },
correctedTechStep: { select: { key: true } },
},
});
return corrections.map((correction) => ({
id: correction.id,
stepId: correction.step.id,
recipeId: correction.step.recipeId,
clauseText: correction.step.description.slice(correction.start, correction.end),
start: correction.start,
end: correction.end,
previousTechStepKey: correction.previousTechStep?.key ?? null,
correctedTechStepKey: correction.correctedTechStep?.key ?? null,
}));
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Persists a batch of `TechStepTrainingSuggestion`s and, for every
* suggestion sourced from a correction, stamps that correction's
* `consumedAt` in the same transaction so a worker run that crashes
* partway through never leaves a correction consumed with no matching
* suggestion, or a suggestion created against a correction still (wrongly)
* eligible to be picked up again by the next run.
*
* @throws {HttpError} `404 TECH_STEP_NOT_FOUND` if any `techStepKey` in the
* batch doesn't match a reference `TechStep` rejects the *whole* batch
* rather than skipping the bad entries, on the theory that a worker
* sending an unknown key is more likely a version-skew bug (its own
* taxonomy copy, `services/tech-step-llm-worker/src/tech-step-taxonomy.ts`,
* drifting from this API's `TechStep` catalog) than a one-off it should
* silently tolerate.
*/
export async function submitTrainingSuggestions(
input: SubmitTrainingSuggestionsInput,
): Promise<{ created: number }> {
try {
const techStepKeys = [
...new Set(input.suggestions.map((suggestion) => suggestion.techStepKey)),
];
const techSteps = await prisma.techStep.findMany({
where: { key: { in: techStepKeys } },
select: { id: true, key: true },
});
const techStepIdByKey = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
const missingKeys = techStepKeys.filter((key) => !techStepIdByKey.has(key));
if (missingKeys.length > 0) {
throw new HttpError(
404,
ErrorCode.TECH_STEP_NOT_FOUND,
`Unknown techStepKey(s): ${missingKeys.join(", ")}`,
);
}
await prisma.$transaction(async (tx) => {
for (const suggestion of input.suggestions) {
// Non-null by construction — every key in `input.suggestions` was
// just confirmed present in `techStepIdByKey` above (the `missingKeys`
// check would have thrown otherwise).
const techStepId = techStepIdByKey.get(suggestion.techStepKey);
if (techStepId === undefined) continue;
await tx.techStepTrainingSuggestion.create({
data: {
techStepId,
locale: suggestion.locale,
suggestedSynonyms: suggestion.suggestedSynonyms,
suggestedUtterances: suggestion.suggestedUtterances,
sourceType: suggestion.sourceType,
sourceCorrectionId: suggestion.sourceCorrectionId ?? null,
},
});
if (suggestion.sourceCorrectionId !== null && suggestion.sourceCorrectionId !== undefined) {
await tx.stepTechStepCorrection.update({
where: { id: suggestion.sourceCorrectionId },
data: { consumedAt: new Date() },
});
}
}
});
return { created: input.suggestions.length };
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}

View file

@ -0,0 +1,506 @@
import { HttpError } from "@batch-cooking/error-tools";
import {
ErrorCode,
type StepTechStepCorrectionView,
type SubmitTechStepCorrectionInput,
type SubmitTechStepCorrectionResult,
} from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import { assertRecipeVisible, toStepTechStepViews } from "./recipe.service.js";
/**
* User-submitted corrections to a step's detected techniques
* (`StepTechStepCorrection` in schema.prisma) kept in its own module
* rather than folded into `recipe.service.ts`, same "one file per concern"
* split that file itself follows for `tech-step-matcher.ts`. Deliberately
* open to *any* viewer who can see the recipe, not just its author (unlike
* every write path in `recipe.service.ts`, which uses `assertIsAuthor`)
* correcting a mislabeled technique isn't editing the recipe's own
* content, and restricting it to authors would starve the training-data
* feedback loop (`services/tech-step-llm-worker`) of the volume it needs.
*/
type CorrectionWithTechSteps = Prisma.StepTechStepCorrectionGetPayload<{
include: { previousTechStep: true; correctedTechStep: true };
}>;
const correctionInclude = {
previousTechStep: true,
correctedTechStep: true,
} satisfies Prisma.StepTechStepCorrectionInclude;
/**
* Loads `stepId`'s current `description` length (the only thing a
* correction needs from the step itself), or throws `404 STEP_NOT_FOUND`
* if no such step exists, or if it exists but doesn't belong to `recipeId`
* (the route's own `:id`/`:stepId` nesting is meaningless otherwise a
* request naming a real step under the wrong recipe should look identical
* to naming one that doesn't exist, same "don't leak which part was wrong"
* posture `assertRecipeVisible` already has for visibility). Otherwise
* whatever {@link assertRecipeVisible} throws (`404 RECIPE_NOT_FOUND`,
* never `403`) if the recipe exists but isn't visible to the viewer.
*/
async function loadVisibleStepOrThrow(
recipeId: number,
stepId: number,
viewerId: number,
viewerHouseId: number | null,
): Promise<{ id: number; descriptionLength: number }> {
try {
const step = await prisma.step.findUnique({
where: { id: stepId },
select: { id: true, recipeId: true, description: true },
});
if (!step || step.recipeId !== recipeId) {
throw new HttpError(404, ErrorCode.STEP_NOT_FOUND, `Step ${stepId} not found`);
}
await assertRecipeVisible(step.recipeId, viewerId, viewerHouseId);
return { id: step.id, descriptionLength: step.description.length };
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/** Throws `404 TECH_STEP_NOT_FOUND` if any id in `ids` doesn't match a reference `TechStep` row — same shape as `recipe.service.ts`'s `assertIngredientsExist`/`assertUnitsExist` for the recipe payload's own reference ids. */
async function assertTechStepsExist(ids: number[]): Promise<void> {
try {
if (ids.length === 0) return;
const found = await prisma.techStep.findMany({
where: { id: { in: ids } },
select: { id: true },
});
const foundIds = new Set(found.map((techStep) => techStep.id));
const missing = ids.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new HttpError(
404,
ErrorCode.TECH_STEP_NOT_FOUND,
`TechStep ids not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see loadVisibleStepOrThrow's catch comment
}
}
/** Throws `404 INGREDIENT_NOT_FOUND` if any id in `ids` doesn't match a reference `Ingredient` row — same shape as {@link assertTechStepsExist}, checking `input.ingredients[].ingredientId` instead. */
async function assertIngredientsExist(ids: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length === 0) return;
const found = await prisma.ingredient.findMany({
where: { id: { in: uniqueIds } },
select: { id: true },
});
const foundIds = new Set(found.map((ingredient) => ingredient.id));
const missing = uniqueIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new HttpError(
404,
ErrorCode.INGREDIENT_NOT_FOUND,
`Ingredient ids not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see loadVisibleStepOrThrow's catch comment
}
}
/** Throws `404 UNIT_NOT_FOUND` if any id in `ids` doesn't match a reference `Unit` row — same shape as {@link assertIngredientsExist}, checking `input.ingredients[].unitId` instead. */
async function assertUnitsExist(ids: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length === 0) return;
const found = await prisma.unit.findMany({
where: { id: { in: uniqueIds } },
select: { id: true },
});
const foundIds = new Set(found.map((unit) => unit.id));
const missing = uniqueIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new HttpError(
404,
ErrorCode.UNIT_NOT_FOUND,
`Unit ids not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see loadVisibleStepOrThrow's catch comment
}
}
/** Throws `404 UTENSIL_NOT_FOUND` if any id in `ids` doesn't match a reference `Utensil` row — same shape as {@link assertIngredientsExist}, checking `input.utensils[].utensilId` instead. */
async function assertUtensilsExist(ids: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length === 0) return;
const found = await prisma.utensil.findMany({
where: { id: { in: uniqueIds } },
select: { id: true },
});
const foundIds = new Set(found.map((utensil) => utensil.id));
const missing = uniqueIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new HttpError(
404,
ErrorCode.UTENSIL_NOT_FOUND,
`Utensil ids not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see loadVisibleStepOrThrow's catch comment
}
}
/**
* Renumbers every one of `stepId`'s `StepTechStep` rows' `order` by
* ascending `start` (nulls-still-possible legacy rows, see that model's
* schema doc comment, sort last) the dense, reading-order 0-based
* sequence `@@id([stepId, order])` requires, regardless of whether a
* caller just inserted, updated, or deleted a row. Simpler and less
* error-prone than shifting only the affected neighbors' `order` by hand.
*
* Two passes, through a disjoint negative range first: updating straight
* into the final 0..N-1 positions in one pass risks a transient
* `(stepId, order)` collision (e.g. the row destined for `order: 0` isn't
* necessarily the one already sitting there) `order` is always `>= 0`
* in real usage, so a negative range can never collide with a live row.
*
* Exported for `scripts/backfill-tech-steps.ts` to reuse after it
* recomputes just the `"auto"` subset of a step's rows, so the combined
* `"auto"` + `"manual"` sequence still ends up in one coherent
* reading-order.
*/
export async function renumberStepTechSteps(
tx: Prisma.TransactionClient,
stepId: number,
): Promise<void> {
const rows = await tx.stepTechStep.findMany({ where: { stepId } });
const sorted = [...rows].sort(
(a, b) => (a.start ?? Number.POSITIVE_INFINITY) - (b.start ?? Number.POSITIVE_INFINITY),
);
for (const [index, row] of sorted.entries()) {
await tx.stepTechStep.update({
where: { stepId_order: { stepId, order: row.order } },
data: { order: -(index + 1) },
});
}
for (const [index] of sorted.entries()) {
await tx.stepTechStep.update({
where: { stepId_order: { stepId, order: -(index + 1) } },
data: { order: index },
});
}
}
/** One ingredient/utensil mention the viewer themselves selected, ready to persist — see {@link applyManualCorrection}'s own doc comment for the "manual replaces all" semantics these are written under. */
interface ManualIngredientMention {
ingredientId: number;
quantity: number | null;
unitId: number | null;
start: number;
end: number;
}
interface ManualUtensilMention {
utensilId: number;
start: number;
end: number;
}
/**
* Applies a correction's *effect* on `stepId`'s real `StepTechStep`
* sequence, immediately not just recorded as a pending suggestion for
* `services/tech-step-llm-worker` to eventually process (see
* `StepTechStepCorrection`'s schema doc comment; this is *in addition to*
* that offline feedback loop, not instead of it). `previousTechStepId`/
* `correctedTechStepId` mean exactly what they do on
* `StepTechStepCorrection` itself (`SubmitTechStepCorrectionInput`'s doc
* comment, `packages/shared`):
*
* - `correctedTechStepId` set (add or relabel): a `"manual"` row is
* written at the correction's own `[start, end)` updating the
* existing entry in place when one matching `previousTechStepId`
* overlaps this span, otherwise inserting a new one. No `contextStart`/
* `contextEnd` a correction only ever carries the tight span the user
* themselves selected/clicked, nothing wider to highlight around it.
* - `previousTechStepId` alone (remove, `correctedTechStepId: null`): the
* matching existing entry is deleted outright (cascading away any
* ingredient/utensil metadata attached to it, auto or manual nothing
* left to attach metadata to once the technique itself is gone). A
* no-op if none matches (nothing to remove).
*
* `metadata`, when given (only ever alongside a real `correctedTechStepId`
* enforced by `submitTechStepCorrectionSchema`, not re-checked here),
* replaces *every* `StepTechStepIngredient`/`StepTechStepUtensil` row on
* this occurrence `source: "auto"` (the classifier's own detection) and
* any earlier `"manual"` set alike with the newly-submitted one. This is
* "le manuel remplace tout" (confirmed with the user): the resolved
* `order` this technique ends up at (whichever branch above produced it)
* is the same `techStepOrder` both metadata tables key on, so the same
* `deleteMany` + `createMany` pair below is correct whether this call just
* updated an existing row (which may already carry auto-detected
* metadata) or created a brand new one (nothing to delete yet a no-op
* `deleteMany`, not a special case).
*
* Runs inside the same transaction {@link submitTechStepCorrection} uses
* for the audit-trail insert, so a request never leaves any of these
* effects (the permanent correction record, the live sequence change, the
* metadata replacement) only partially applied.
*/
async function applyManualCorrection(
tx: Prisma.TransactionClient,
stepId: number,
span: { start: number; end: number },
previousTechStepId: number | null,
correctedTechStepId: number | null,
metadata?: { ingredients: ManualIngredientMention[]; utensils: ManualUtensilMention[] },
): Promise<void> {
const existing = await tx.stepTechStep.findMany({ where: { stepId } });
const target =
previousTechStepId !== null
? existing.find(
(row) =>
row.techStepId === previousTechStepId &&
row.start !== null &&
row.end !== null &&
row.start < span.end &&
span.start < row.end,
)
: undefined;
if (correctedTechStepId !== null) {
const order = target
? target.order
: existing.reduce((max, row) => Math.max(max, row.order), -1) + 1;
if (target) {
await tx.stepTechStep.update({
where: { stepId_order: { stepId, order } },
data: {
techStepId: correctedTechStepId,
start: span.start,
end: span.end,
contextStart: null,
contextEnd: null,
source: "manual",
},
});
} else {
await tx.stepTechStep.create({
data: {
stepId,
techStepId: correctedTechStepId,
order,
start: span.start,
end: span.end,
source: "manual",
},
});
}
if (metadata !== undefined) {
await tx.stepTechStepIngredient.deleteMany({ where: { stepId, techStepOrder: order } });
await tx.stepTechStepUtensil.deleteMany({ where: { stepId, techStepOrder: order } });
if (metadata.ingredients.length > 0) {
await tx.stepTechStepIngredient.createMany({
data: metadata.ingredients.map((ingredient) => ({
stepId,
techStepOrder: order,
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity,
unitId: ingredient.unitId,
start: ingredient.start,
end: ingredient.end,
source: "manual",
})),
});
}
if (metadata.utensils.length > 0) {
await tx.stepTechStepUtensil.createMany({
data: metadata.utensils.map((utensil) => ({
stepId,
techStepOrder: order,
utensilId: utensil.utensilId,
start: utensil.start,
end: utensil.end,
source: "manual",
})),
});
}
}
} else if (target) {
await tx.stepTechStep.delete({ where: { stepId_order: { stepId, order: target.order } } });
}
await renumberStepTechSteps(tx, stepId);
}
function toCorrectionView(correction: CorrectionWithTechSteps): StepTechStepCorrectionView {
return {
id: correction.id,
start: correction.start,
end: correction.end,
previousTechStep: correction.previousTechStep
? { id: correction.previousTechStep.id, key: correction.previousTechStep.key }
: null,
correctedTechStep: correction.correctedTechStep
? { id: correction.correctedTechStep.id, key: correction.correctedTechStep.key }
: null,
createdAt: correction.createdAt.toISOString(),
};
}
/**
* Records one correction to `stepId`'s detected techniques, submitted by
* `correctorId`, and immediately applies its effect to the step's real
* `StepTechStep` sequence (a `"manual"`-tagged row see
* {@link applyManualCorrection}) see
* {@link SubmitTechStepCorrectionInput}'s doc comment (`packages/shared`)
* for what `previousTechStepId`/`correctedTechStepId` each mean. The audit
* record itself is never edited/deleted afterward (see
* `StepTechStepCorrection`'s schema doc comment) only the live sequence
* changes on a later correction to the same span.
*
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` see
* {@link loadVisibleStepOrThrow}. `400 INVALID_CORRECTION_SPAN` if
* `start`/`end` (the correction's own span, or any of
* `input.ingredients`/`input.utensils`' own spans) fall outside the
* step's current `description` (it may have been edited since the user
* last saw it). `404 TECH_STEP_NOT_FOUND`/`404 INGREDIENT_NOT_FOUND`/
* `404 UNIT_NOT_FOUND`/`404 UTENSIL_NOT_FOUND` if any referenced id
* doesn't exist.
*/
export async function submitTechStepCorrection(
recipeId: number,
stepId: number,
input: SubmitTechStepCorrectionInput,
correctorId: number,
viewerHouseId: number | null,
): Promise<SubmitTechStepCorrectionResult> {
try {
const step = await loadVisibleStepOrThrow(recipeId, stepId, correctorId, viewerHouseId);
const spans = [
{ start: input.start, end: input.end },
...(input.ingredients ?? []),
...(input.utensils ?? []),
];
for (const span of spans) {
if (span.start >= step.descriptionLength || span.end > step.descriptionLength) {
throw new HttpError(
400,
ErrorCode.INVALID_CORRECTION_SPAN,
`Span [${span.start}, ${span.end}) falls outside step ${stepId}'s description (length ${step.descriptionLength})`,
);
}
}
const techStepIds = [input.previousTechStepId, input.correctedTechStepId].filter(
(id): id is number => id !== null && id !== undefined,
);
await assertTechStepsExist(techStepIds);
await assertIngredientsExist((input.ingredients ?? []).map((i) => i.ingredientId));
await assertUnitsExist(
(input.ingredients ?? []).flatMap((i) =>
i.unitId !== null && i.unitId !== undefined ? [i.unitId] : [],
),
);
await assertUtensilsExist((input.utensils ?? []).map((u) => u.utensilId));
const { correction, techSteps } = await prisma.$transaction(async (tx) => {
const createdCorrection = await tx.stepTechStepCorrection.create({
data: {
stepId: step.id,
correctorId,
start: input.start,
end: input.end,
previousTechStepId: input.previousTechStepId ?? null,
correctedTechStepId: input.correctedTechStepId ?? null,
},
include: correctionInclude,
});
await applyManualCorrection(
tx,
step.id,
{ start: input.start, end: input.end },
input.previousTechStepId ?? null,
input.correctedTechStepId ?? null,
input.ingredients === undefined && input.utensils === undefined
? undefined
: {
ingredients: (input.ingredients ?? []).map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity ?? null,
unitId: ingredient.unitId ?? null,
start: ingredient.start,
end: ingredient.end,
})),
utensils: (input.utensils ?? []).map((utensil) => ({
utensilId: utensil.utensilId,
start: utensil.start,
end: utensil.end,
})),
},
);
// Same nested `ingredients`/`utensils` include as `recipe.service.ts`'s
// `recipeInclude` — `toStepTechStepViews` (reused below) expects it,
// so the fresh sequence read right after a manual correction resolves
// exactly the same way a normal `GET /recipes/:id` would.
const freshTechSteps = await tx.stepTechStep.findMany({
where: { stepId: step.id },
orderBy: { order: "asc" },
include: {
techStep: true,
ingredients: {
include: {
ingredient: {
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
},
unit: true,
},
},
utensils: { include: { utensil: true } },
},
});
return { correction: createdCorrection, techSteps: freshTechSteps };
});
return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
} catch (err) {
throw err; // see loadVisibleStepOrThrow's catch comment
}
}
/**
* Every correction submitted so far for `stepId`, most recent first
* mainly useful for a user checking what's already been submitted (by
* anyone) for a span before adding another (see `StepTechStepCorrectionView`'s
* doc comment, `packages/shared`).
*
* @throws {HttpError} `404 STEP_NOT_FOUND`/`404 RECIPE_NOT_FOUND` see {@link loadVisibleStepOrThrow}.
*/
export async function listTechStepCorrections(
recipeId: number,
stepId: number,
viewerId: number,
viewerHouseId: number | null,
): Promise<StepTechStepCorrectionView[]> {
try {
const step = await loadVisibleStepOrThrow(recipeId, stepId, viewerId, viewerHouseId);
const corrections = await prisma.stepTechStepCorrection.findMany({
where: { stepId: step.id },
orderBy: { createdAt: "desc" },
include: correctionInclude,
});
return corrections.map(toCorrectionView);
} catch (err) {
throw err; // see loadVisibleStepOrThrow's catch comment
}
}

View file

@ -4,6 +4,7 @@ import {
createRecipeSchema, createRecipeSchema,
ErrorCode, ErrorCode,
listRecipesSchema, listRecipesSchema,
submitTechStepCorrectionSchema,
updateRecipeSchema, updateRecipeSchema,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { Router } from "express"; import { Router } from "express";
@ -17,6 +18,10 @@ import {
removeFavorite, removeFavorite,
updateRecipe, updateRecipe,
} from "./recipe.service.js"; } from "./recipe.service.js";
import {
listTechStepCorrections,
submitTechStepCorrection,
} from "./recipe-tech-step-correction.service.js";
/** Router mounted at `/recipes` in app.ts. Every route requires a session — the catalog is shared across households, not public (same reasoning as `planning`/`house`: it's app content, not signup-time reference data). */ /** Router mounted at `/recipes` in app.ts. Every route requires a session — the catalog is shared across households, not public (same reasoning as `planning`/`house`: it's app content, not signup-time reference data). */
export const recipeRouter = Router(); export const recipeRouter = Router();
@ -30,6 +35,15 @@ function parseRecipeId(rawId: string | undefined): number {
return id; return id;
} }
/** Same shape as {@link parseRecipeId}, for the `:stepId` route param of the tech-step-correction routes below — a distinct function only so the error message names the right param. */
function parseStepId(rawId: string | undefined): number {
const id = Number(rawId);
if (!Number.isInteger(id)) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "stepId must be an integer");
}
return id;
}
recipeRouter.get( recipeRouter.get(
"/", "/",
requireAuth, requireAuth,
@ -109,3 +123,29 @@ recipeRouter.delete(
res.status(204).end(); res.status(204).end();
}), }),
); );
// Open to any authenticated viewer who can see the recipe, not just its
// author — see recipe-tech-step-correction.service.ts's own doc comment
// for why.
recipeRouter.post(
"/:id/steps/:stepId/corrections",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parseRecipeId(req.params.id);
const stepId = parseStepId(req.params.stepId);
const input = submitTechStepCorrectionSchema.parse(req.body);
const { id: correctorId, houseId } = res.locals.userProfile;
res.status(201).json(await submitTechStepCorrection(id, stepId, input, correctorId, houseId));
}),
);
recipeRouter.get(
"/:id/steps/:stepId/corrections",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const id = parseRecipeId(req.params.id);
const stepId = parseStepId(req.params.stepId);
const { id: viewerId, houseId } = res.locals.userProfile;
res.status(200).json(await listTechStepCorrections(id, stepId, viewerId, houseId));
}),
);

View file

@ -45,7 +45,29 @@ function recipeInclude(viewerId: number) {
steps: { steps: {
orderBy: { order: "asc" }, orderBy: { order: "asc" },
include: { include: {
techSteps: { orderBy: { order: "asc" }, include: { techStep: true } }, techSteps: {
orderBy: { order: "asc" },
include: {
techStep: true,
// Same `allergies`/`diets` nesting as this function's own
// top-level `ingredients` include above — reused by
// `toIngredientView` so a mentioned ingredient resolves to the
// exact same `IngredientView` shape as the recipe's main
// ingredient list, not a second, thinner shape.
ingredients: {
include: {
ingredient: {
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
},
unit: true,
},
},
utensils: { include: { utensil: true } },
},
},
}, },
}, },
diets: { include: { diet: true } }, diets: { include: { diet: true } },
@ -56,11 +78,13 @@ function recipeInclude(viewerId: number) {
type RecipeWithDetails = Prisma.RecipeGetPayload<{ type RecipeWithDetails = Prisma.RecipeGetPayload<{
include: ReturnType<typeof recipeInclude>; include: ReturnType<typeof recipeInclude>;
}>; }>;
type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"]; /** Exported — `shopping-list.service.ts` fetches its own, narrower ingredient include (no need for a whole `RecipeWithDetails`) but shapes the same `allergies`/`diets` nesting, so it reuses {@link toIngredientView} directly instead of re-deriving this type. */
type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"]; export type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
/** Exported — see {@link IngredientWithDetails}, same reuse by `shopping-list.service.ts`. */
export type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
/** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. */ /** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. Exported — reused as-is by `shopping-list.service.ts` (a shopping list resolves the same reference data, no need for a second copy of this mapping). */
function toUnitView(unit: UnitWithDetails): UnitView { export function toUnitView(unit: UnitWithDetails): UnitView {
return { return {
id: unit.id, id: unit.id,
key: unit.key, key: unit.key,
@ -69,8 +93,8 @@ function toUnitView(unit: UnitWithDetails): UnitView {
}; };
} }
/** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */ /** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same flattening as `reference.service.ts`'s `getIngredients`. Exported — see {@link toUnitView}'s doc comment, same reuse by `shopping-list.service.ts`. */
function toIngredientView(ingredient: IngredientWithDetails): IngredientView { export function toIngredientView(ingredient: IngredientWithDetails): IngredientView {
return { return {
id: ingredient.id, id: ingredient.id,
key: ingredient.key, key: ingredient.key,
@ -132,19 +156,48 @@ function toRecipeSummaryView(recipe: RecipeWithDetails): RecipeSummaryView {
* existed) still has a perfectly good match to show, just without the * existed) still has a perfectly good match to show, just without the
* wider highlight, so those two are included only when both are present * wider highlight, so those two are included only when both are present
* rather than dropping the whole entry over a still-missing "nice to have". * rather than dropping the whole entry over a still-missing "nice to have".
*
* Exported also called by `recipe-tech-step-correction.service.ts` to
* shape the fresh `StepTechStep` sequence it returns right after applying
* a manual correction, so both places convert the exact same way rather
* than risking two slightly different views of the same rows.
*/ */
function toStepTechStepViews( export function toStepTechStepViews(
techSteps: RecipeWithDetails["steps"][number]["techSteps"], techSteps: RecipeWithDetails["steps"][number]["techSteps"],
): StepTechStepView[] { ): StepTechStepView[] {
const views: StepTechStepView[] = []; const views: StepTechStepView[] = [];
for (const stepTechStep of techSteps) { for (const stepTechStep of techSteps) {
const { start, end, contextStart, contextEnd, techStep } = stepTechStep; const { start, end, contextStart, contextEnd, techStep, source, ingredients, utensils } =
stepTechStep;
if (start === null || end === null) continue; if (start === null || end === null) continue;
views.push({ views.push({
techStep: { id: techStep.id, key: techStep.key }, techStep: { id: techStep.id, key: techStep.key },
start, start,
end, end,
// `source` is a plain DB `String`, not a Prisma enum (see
// `StepTechStep`'s schema doc comment) — narrowed here rather than
// trusting the column's own type, so a value this app never wrote
// (a manual DB edit, a future migration gone wrong) degrades to the
// safer "auto" reading instead of surfacing an invalid
// `StepTechStepView.source` to the frontend.
source: source === "manual" ? "manual" : "auto",
...(contextStart !== null && contextEnd !== null ? { contextStart, contextEnd } : {}), ...(contextStart !== null && contextEnd !== null ? { contextStart, contextEnd } : {}),
ingredients: ingredients.map((stepTechStepIngredient) => ({
ingredient: toIngredientView(stepTechStepIngredient.ingredient),
quantity:
stepTechStepIngredient.quantity === null ? null : Number(stepTechStepIngredient.quantity),
unit: stepTechStepIngredient.unit === null ? null : toUnitView(stepTechStepIngredient.unit),
start: stepTechStepIngredient.start,
end: stepTechStepIngredient.end,
// Same narrowing posture as the technique's own `source` above.
source: stepTechStepIngredient.source === "manual" ? "manual" : "auto",
})),
utensils: utensils.map((stepTechStepUtensil) => ({
utensil: { id: stepTechStepUtensil.utensil.id, key: stepTechStepUtensil.utensil.key },
start: stepTechStepUtensil.start,
end: stepTechStepUtensil.end,
source: stepTechStepUtensil.source === "manual" ? "manual" : "auto",
})),
}); });
} }
return views; return views;
@ -539,6 +592,22 @@ async function createRecipeInternal(
contextStart: match.contextStart, contextStart: match.contextStart,
contextEnd: match.contextEnd, contextEnd: match.contextEnd,
order, order,
ingredients: {
create: match.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity,
unitId: ingredient.unitId,
start: ingredient.start,
end: ingredient.end,
})),
},
utensils: {
create: match.utensils.map((utensil) => ({
utensilId: utensil.utensilId,
start: utensil.start,
end: utensil.end,
})),
},
})), })),
}, },
})), })),

View file

@ -7,6 +7,7 @@ import {
getSources, getSources,
getTechSteps, getTechSteps,
getUnits, getUnits,
getUtensils,
} from "./reference.service.js"; } from "./reference.service.js";
/** /**
@ -55,6 +56,13 @@ referenceRouter.get(
}), }),
); );
referenceRouter.get(
"/utensils",
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await getUtensils());
}),
);
referenceRouter.get( referenceRouter.get(
"/sources", "/sources",
wrapAsyncHandler(async (_req, res) => { wrapAsyncHandler(async (_req, res) => {

View file

@ -5,6 +5,7 @@ import type {
SourceView, SourceView,
TechStepView, TechStepView,
UnitView, UnitView,
UtensilView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
@ -85,6 +86,19 @@ export async function getTechSteps(): Promise<TechStepView[]> {
} }
} }
/**
* All reference cooking utensils, ordered by key (see {@link getDiets} for
* why) small, static list (see `reference-seed-data.ts`'s `UTENSILS`),
* same bare `id`/`key` shape as {@link getTechSteps}.
*/
export async function getUtensils(): Promise<UtensilView[]> {
try {
return await prisma.utensil.findMany({ orderBy: { key: "asc" } });
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/** /**
* Every implemented recipe source, ordered by name (not `key` unlike * Every implemented recipe source, ordered by name (not `key` unlike
* every other reference catalog, `name` here *is* the display string a * every other reference catalog, `name` here *is* the display string a

View file

@ -0,0 +1,36 @@
import { parseDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { ErrorCode, getShoppingListSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { getShoppingListForDate } from "./shopping-list.service.js";
/** Router mounted at `/shopping-list` in app.ts. */
export const shoppingListRouter = Router();
/**
* Returns the authenticated user's household's shopping list for the week
* covering `?date=` (`YYYY-MM-DD`) every ingredient line of every recipe
* planned that week, summed (see {@link getShoppingListForDate}). Always
* `200`, never `null` no household or nothing planned that week both
* come back as a normal `ShoppingListView` with an empty `items` array.
*/
shoppingListRouter.get(
"/",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = getShoppingListSchema.parse(req.query);
const date = parseDateOnly(input.date);
if (date === null) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a real calendar date: ${input.date}`,
);
}
const shoppingList = await getShoppingListForDate(res.locals.userProfile.houseId, date);
res.status(200).json(shoppingList);
}),
);

View file

@ -0,0 +1,154 @@
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
import type {
IngredientView,
ShoppingListItemView,
ShoppingListView,
UnitView,
} from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
/** Prisma `include` for a `Planning` query that needs, for every item, just enough of its recipe to compute a shopping list — `portions` (to scale `RecipeIngredient.quantity`) and the ingredient lines themselves, each resolved the same way `recipe.service.ts`'s own `recipeInclude` resolves them (so {@link toIngredientView}/{@link toUnitView} can be reused as-is). Deliberately narrower than a full `RecipeView` fetch — steps/diets/favorites are never read here. */
function shoppingListPlanningInclude() {
return {
items: {
include: {
recipe: {
select: {
portions: true,
ingredients: {
include: {
ingredient: {
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
},
unit: true,
},
},
},
},
},
},
} satisfies Prisma.PlanningInclude;
}
type PlanningWithIngredients = Prisma.PlanningGetPayload<{
include: ReturnType<typeof shoppingListPlanningInclude>;
}>;
/** Accumulates a running sum per `(ingredientId, unitId)` pair while walking every planning item's ingredient lines — see {@link aggregateShoppingList}. */
interface RunningTotal {
ingredient: IngredientView;
unit: UnitView;
quantity: number;
}
/**
* Sums every ingredient line across `items`, each scaled by that planning
* item's own portion count relative to its recipe's as-written yield
* (`RecipeIngredient.quantity × PlanningItem.portions / Recipe.portions`,
* see `PlanningItem.portions`'s doc comment in schema.prisma for why the
* two can differ). Grouped by `(ingredientId, unitId)` **not** just
* `ingredientId` since summing across units isn't implemented yet (see
* `ShoppingListItemView`'s doc comment): the same ingredient requested in
* two different units stays two separate lines rather than silently
* guessing a conversion. Pure/synchronous, factored out from
* {@link getShoppingListForDate} so the aggregation itself is testable
* without a database round-trip.
*/
function aggregateShoppingList(items: PlanningWithIngredients["items"]): ShoppingListItemView[] {
const totals = new Map<string, RunningTotal>();
for (const item of items) {
const scale = item.portions / item.recipe.portions;
for (const recipeIngredient of item.recipe.ingredients) {
const key = `${recipeIngredient.ingredientId}:${recipeIngredient.unitId}`;
const addedQuantity = Number(recipeIngredient.quantity) * scale;
const existing = totals.get(key);
if (existing) {
existing.quantity += addedQuantity;
} else {
totals.set(key, {
ingredient: toIngredientView(recipeIngredient.ingredient),
unit: toUnitView(recipeIngredient.unit),
quantity: addedQuantity,
});
}
}
}
// Deterministic order (by the ingredient's stable `key`, not its id —
// insertion order would otherwise depend on which recipe happened to be
// read first) — the frontend re-sorts by translated label/aisle for
// display, this is just so two identical plannings always produce the
// same JSON.
return [...totals.values()].sort((a, b) => a.ingredient.key.localeCompare(b.ingredient.key));
}
/**
* Builds the household's shopping list for the week covering `date`
* every ingredient line of every recipe planned that week, aggregated (see
* {@link aggregateShoppingList}). `date` is whatever the caller wants "that
* week" to mean, same convention as `planning.service.ts`'s
* `getPlanningForDate` (a caller-parsed `?date=`, not necessarily a
* Monday).
*
* Unlike `getPlanningForDate`, this **never** returns `null` no household
* and "no planning covers this week yet" both degrade to an empty `items`
* array on an otherwise normal `ShoppingListView` (the week's date range is
* always computable from `date` alone, even with nothing planned in it),
* rather than a separate "nothing to show" state the frontend would have to
* branch on.
*/
export async function getShoppingListForDate(
houseId: number | null,
date: DateTime,
): Promise<ShoppingListView> {
try {
const weekStart = getWeekStart(toDateOnly(date));
const weekFinish = weekStart.plus({ days: 6 });
const emptyList: ShoppingListView = {
startDate: weekStart.toJSDate().toISOString(),
finishDate: weekFinish.toJSDate().toISOString(),
items: [],
};
if (houseId === null) {
return emptyList;
}
// Same "covering range" lookup as getPlanningForDate — see that
// function's doc comment for why this compares against a UTC-midnight
// JS Date rather than `weekStart`/`weekFinish` directly.
const dateOnly = toDateOnly(date).toJSDate();
const planning = await prisma.planning.findFirst({
where: {
houseId,
startDate: { lte: dateOnly },
finishDate: { gte: dateOnly },
},
orderBy: { startDate: "desc" },
include: shoppingListPlanningInclude(),
});
if (!planning) {
return emptyList;
}
return {
startDate: planning.startDate.toISOString(),
finishDate: planning.finishDate.toISOString(),
items: aggregateShoppingList(planning.items),
};
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
// already logs it, see `error-logger.ts`) is what actually handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
}
}

View file

@ -11,10 +11,8 @@ import {
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { findImportedRecipeIds } from "../../db/recipe-source-sync.js"; import { findImportedRecipeIds } from "../../db/recipe-source-sync.js";
import { import {
type IngredientMatchEntry,
loadIngredientCatalog, loadIngredientCatalog,
loadUnitCatalog, loadUnitCatalog,
type UnitMatchEntry,
} from "../../lib/recipe-matching/ingredient-matcher.js"; } from "../../lib/recipe-matching/ingredient-matcher.js";
import { import {
mergeDuplicateIngredients, mergeDuplicateIngredients,
@ -29,7 +27,7 @@ import { RecipeSourceError } from "../../lib/recipe-sources/recipe-source-errors
import { getRecipeSource } from "../../lib/recipe-sources/recipe-source-registry.js"; import { getRecipeSource } from "../../lib/recipe-sources/recipe-source-registry.js";
import { getHouseSourceIds } from "../house/house.service.js"; import { getHouseSourceIds } from "../house/house.service.js";
import { createImportedRecipe } from "../recipe/recipe.service.js"; import { createImportedRecipe } from "../recipe/recipe.service.js";
import { getIngredients, getUnits } from "../reference/reference.service.js"; import { getIngredients, getUnits, getUtensils } from "../reference/reference.service.js";
/** /**
* Browsing, previewing, and importing a household's *enabled* external * Browsing, previewing, and importing a household's *enabled* external
@ -141,10 +139,13 @@ export async function browseSource(
* techniques with their exact matched span (`matchTechStepSpans`, the same * techniques with their exact matched span (`matchTechStepSpans`, the same
* function `recipe.service.ts` uses at real save time see its doc * function `recipe.service.ts` uses at real save time see its doc
* comment), all against `adapter.locale`'s catalogs. Ingredient/unit * comment), all against `adapter.locale`'s catalogs. Ingredient/unit
* matching itself only has English data today (see `ingredient-matcher.ts`); * matching has data for `"en"`/`"fr"` today (see `ingredient-matcher.ts`);
* a non-English-locale source simply gets `ingredient`/`unit: null` on * `loadIngredientCatalog`/`loadUnitCatalog` are always called with
* every line, the same graceful "no matching-language data" degradation * `adapter.locale` directly, never specially skipped for a particular
* `translateRecipe` already has. * one a source whose locale has no label table of its own just gets back
* empty catalogs from those two loaders, so every line's `ingredient`/
* `unit` end up `null` the same way, the same graceful "no
* matching-language data" degradation `translateRecipe` already has.
* *
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet. * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household. * @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household.
@ -177,10 +178,8 @@ export async function previewSourceItem(
matches: await techStepClassifier.matchTechStepSpans(step.description, adapter.locale), matches: await techStepClassifier.matchTechStepSpans(step.description, adapter.locale),
})), })),
), ),
adapter.locale === "en" loadIngredientCatalog(adapter.locale),
? loadIngredientCatalog() loadUnitCatalog(adapter.locale),
: Promise.resolve<IngredientMatchEntry[]>([]),
adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve<UnitMatchEntry[]>([]),
prisma.techStep.findMany({ select: { id: true, key: true } }), prisma.techStep.findMany({ select: { id: true, key: true } }),
]); ]);
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep])); const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
@ -189,10 +188,16 @@ export async function previewSourceItem(
parsed.ingredients, parsed.ingredients,
ingredientCatalog, ingredientCatalog,
unitCatalog, unitCatalog,
adapter.locale,
); );
const [ingredientViews, unitViews] = await Promise.all([getIngredients(), getUnits()]); const [ingredientViews, unitViews, utensilViews] = await Promise.all([
getIngredients(),
getUnits(),
getUtensils(),
]);
const ingredientById = new Map(ingredientViews.map((view) => [view.id, view])); const ingredientById = new Map(ingredientViews.map((view) => [view.id, view]));
const unitById = new Map(unitViews.map((view) => [view.id, view])); const unitById = new Map(unitViews.map((view) => [view.id, view]));
const utensilById = new Map(utensilViews.map((view) => [view.id, view]));
// A source's raw ingredient lines aren't deduplicated by the matcher — // A source's raw ingredient lines aren't deduplicated by the matcher —
// two different lines (e.g. "Egg Yolks"/"Eggs") can resolve to the same // two different lines (e.g. "Egg Yolks"/"Eggs") can resolve to the same
@ -225,6 +230,36 @@ export async function previewSourceItem(
end: match.end, end: match.end,
contextStart: match.contextStart, contextStart: match.contextStart,
contextEnd: match.contextEnd, contextEnd: match.contextEnd,
// A draft preview has no persisted `StepTechStep` row to
// read a real `source` from at all (it isn't a saved
// recipe yet — see `DraftRecipeStepView`'s own doc
// comment) — always the classifier's own live match,
// never a correction, so always "auto".
source: "auto",
ingredients: match.ingredients.flatMap((mention) => {
const ingredient = ingredientById.get(mention.ingredientId);
// Same drift guard as `techStep` above — an ingredientId
// the matcher resolved but that's since vanished from the
// catalog is dropped rather than shown with a hole in it.
if (!ingredient) return [];
return [
{
ingredient,
quantity: mention.quantity,
unit: mention.unitId !== null ? (unitById.get(mention.unitId) ?? null) : null,
start: mention.start,
end: mention.end,
// Same reasoning as this match's own `source` above — a draft preview only ever holds live classifier output.
source: "auto" as const,
},
];
}),
utensils: match.utensils.flatMap((mention) => {
const utensil = utensilById.get(mention.utensilId);
return utensil
? [{ utensil, start: mention.start, end: mention.end, source: "auto" as const }]
: [];
}),
}, },
] ]
: []; : [];

View file

@ -1,56 +1,118 @@
import { pathToFileURL } from "node:url";
import { prisma } from "../db/prisma.js"; import { prisma } from "../db/prisma.js";
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js"; import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
import { renumberStepTechSteps } from "../modules/recipe/recipe-tech-step-correction.service.js";
/** /**
* One-off maintenance script: recomputes every existing `Step`'s * Recomputes every existing `Step`'s `"auto"`-sourced `StepTechStep`
* `StepTechStep` sequence against the *current* classifier * entries against the *current* classifier
* (`tech-step-matcher.ts`/`tech-step-training-data.ts`), the same way * (`tech-step-matcher.ts`, delegating to `services/tech-step-intent-service`),
* `updateRecipe` does when a user resaves a recipe through the UI * the same way `updateRecipe` does when a user resaves a recipe through the UI
* always `"fr"` (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`; there's * always `"fr"` (`DEFAULT_TECH_STEP_LOCALE` in `recipe.service.ts`; there's
* no persisted per-recipe locale to recover for a step that already exists, * no persisted per-recipe locale to recover for a step that already
* so this matches real resave behavior exactly rather than guessing). * exists, so this matches real resave behavior exactly rather than
* guessing).
* *
* Needed because tech-step detection only ever runs at create/update time * Needed because tech-step detection only ever runs at create/update time
* (`recipe.service.ts`'s `matchStepsTechSteps`), never retroactively a * (`recipe.service.ts`'s `matchStepsTechSteps`), never retroactively a
* step saved before a classifier/corpus change (new vocabulary, or the * step saved before a classifier/corpus change (new vocabulary, or the
* `contextStart`/`contextEnd` columns this same session added) keeps * `contextStart`/`contextEnd` columns a previous session added) keeps
* whatever it was matched with at the time until it's next resaved. Run * whatever it was matched with at the time until it's next resaved.
* this after a corpus change to bring every existing step in sync without
* asking users to open and resave every recipe by hand:
* *
* pnpm --filter api exec tsx src/scripts/backfill-tech-steps.ts * `"manual"`-sourced entries (a viewer's correction, applied immediately
* see `recipe-tech-step-correction.service.ts`'s `applyManualCorrection`)
* are never touched by this: only rows with `source: "auto"` are deleted
* and recreated, and any fresh classifier match overlapping an existing
* `"manual"` entry's span is dropped rather than inserted a manual
* correction is meant to *override* the classifier at that exact spot,
* and recomputing must never silently reintroduce (or duplicate-highlight)
* what a user already corrected. `renumberStepTechSteps`
* (`recipe-tech-step-correction.service.ts`) folds the surviving `"auto"` +
* untouched `"manual"` rows back into one coherent reading-order sequence
* afterward.
* *
* Safe to re-run: each step's technique sequence is fully replaced (delete * Exported (not just called from this file's own CLI guard below) so
* + recreate) from the classifier's current output, same as a real edit * `retrain-tech-steps.ts` can run it as one step of its own larger
* running it twice in a row with no corpus change in between is a no-op. * maintainer workflow, without shelling out to a second process.
*
* Safe to re-run: with no manual entries and no corpus change since the
* last run, this is a no-op (the same `"auto"` matches get deleted and
* recreated identically); with manual entries present, they're preserved
* on every run by construction.
*/ */
async function backfillTechSteps(): Promise<void> { export async function backfillTechSteps(): Promise<{ total: number; changed: number }> {
const steps = await prisma.step.findMany({ select: { id: true, description: true } }); const steps = await prisma.step.findMany({ select: { id: true, description: true } });
console.info(`Recomputing tech steps for ${steps.length} step(s)...`); console.info(`Recomputing tech steps for ${steps.length} step(s)...`);
let changed = 0; let changed = 0;
for (const step of steps) { for (const step of steps) {
const matches = await techStepClassifier.matchTechStepSpans(step.description, "fr"); const matches = await techStepClassifier.matchTechStepSpans(step.description, "fr");
await prisma.$transaction([
prisma.stepTechStep.deleteMany({ where: { stepId: step.id } }), await prisma.$transaction(async (tx) => {
prisma.stepTechStep.createMany({ const manualRows = await tx.stepTechStep.findMany({
data: matches.map((match, order) => ({ where: { stepId: step.id, source: "manual" },
});
const nonOverlappingMatches = matches.filter(
(match) =>
!manualRows.some(
(manual) =>
manual.start !== null &&
manual.end !== null &&
manual.start < match.end &&
match.start < manual.end,
),
);
await tx.stepTechStep.deleteMany({ where: { stepId: step.id, source: "auto" } });
if (nonOverlappingMatches.length > 0) {
// Placeholder orders, disjoint from the untouched manual rows'
// existing ones (`renumberStepTechSteps` below folds everything
// into a clean 0..N-1 sequence right after — these just need to
// not collide with `@@id([stepId, order])` for this insert).
const startOrder = manualRows.reduce((max, row) => Math.max(max, row.order), -1) + 1;
await tx.stepTechStep.createMany({
data: nonOverlappingMatches.map((match, index) => ({
stepId: step.id, stepId: step.id,
techStepId: match.techStepId, techStepId: match.techStepId,
order, order: startOrder + index,
start: match.start, start: match.start,
end: match.end, end: match.end,
contextStart: match.contextStart, contextStart: match.contextStart,
contextEnd: match.contextEnd, contextEnd: match.contextEnd,
source: "auto",
})), })),
}), });
]); }
await renumberStepTechSteps(tx, step.id);
});
changed += 1; changed += 1;
} }
console.info(`Done — ${changed} step(s) recomputed.`); console.info(`Done — ${changed} step(s) recomputed.`);
return { total: steps.length, changed };
} }
// Only runs when this file is executed directly (`tsx
// src/scripts/backfill-tech-steps.ts`), not when `retrain-tech-steps.ts`
// imports `backfillTechSteps` above — the standard ESM "is this the entry
// module" check, first needed in this codebase by that new script; every
// prior script here (`seed-runtime.ts`) was always only ever run directly,
// never imported. `pathToFileURL` (not a naive `` `file://${process.argv[1]}` ``
// concatenation) is required for this to actually work on Windows — a
// native Windows path (backslashes, no leading slash before the drive
// letter) doesn't survive being pasted directly after `file://`, so the
// comparison against `import.meta.url` (already a real, correctly-escaped
// `file:///D:/...` URL) always came out false: this guard silently never
// matched, so running this script directly (`tsx
// src/scripts/backfill-tech-steps.ts`) did *nothing* — no error, no
// output, `backfillTechSteps()` simply never called — found only by
// running it for real and noticing zero output where several log lines
// were expected.
const isMainModule = import.meta.url === pathToFileURL(process.argv[1] ?? "").href;
if (isMainModule) {
backfillTechSteps() backfillTechSteps()
.then(() => prisma.$disconnect()) .then(() => prisma.$disconnect())
.catch(async (err) => { .catch(async (err) => {
@ -58,3 +120,4 @@ backfillTechSteps()
await prisma.$disconnect(); await prisma.$disconnect();
process.exit(1); process.exit(1);
}); });
}

View file

@ -0,0 +1,101 @@
import { prisma } from "../db/prisma.js";
import { TECH_STEP_EVAL_DATASET } from "../lib/recipe-matching/tech-step-eval-dataset.js";
import {
computeTechStepMetrics,
type TechStepEvalOutcome,
} from "../lib/recipe-matching/tech-step-evaluator.js";
import { techStepClassifier } from "../lib/recipe-matching/tech-step-matcher.js";
/**
* Candidate thresholds to sweep, `0.05` to `0.95` in `0.05` steps fine
* enough to find a good value without an unreasonable number of full
* `TECH_STEP_EVAL_DATASET` passes (each threshold only needs one
* {@link techStepClassifier.classifyClauses} call per eval case, not a
* retrain see this file's own doc comment for why).
*/
const CANDIDATE_THRESHOLDS = Array.from({ length: 19 }, (_, i) => Math.round((i + 1) * 5) / 100);
/**
* One-off maintainer tool for recalibrating `CONFIDENCE_THRESHOLD`
* (`tech-step-matcher.ts`) after a change to the underlying intent
* classifier most notably, the migration from `node-nlp` to
* `services/tech-step-intent-service` (spaCy): a different model produces a
* differently-shaped confidence score distribution, so a threshold tuned
* against the old classifier has no reason to still be the right cutoff for
* the new one.
*
* Reuses `techStepClassifier.classifyClauses` already public, and
* deliberately *not* threshold-applied (see that method's own doc comment)
* to get every eval case's raw `{anchorUid, intentUid, score}` per clause
* exactly once, then replays `_classifyClause`'s own decision rule
* (`intentUid` if confident enough, `anchorUid` otherwise) locally in this
* script for every candidate threshold. This is what makes a full sweep
* cheap: one classifier pass per eval case regardless of how many
* thresholds are being compared, rather than one full pass *per threshold*.
*
* Prints a threshold -> precision/recall/F1 table and the threshold that
* maximizes aggregate F1 does **not** edit `tech-step-matcher.ts` itself.
* A maintainer reads the table, updates `CONFIDENCE_THRESHOLD` by hand (with
* an updated doc comment recording what run/F1 the new value was calibrated
* against, same as the existing comment's own format), then re-runs
* `retrain-tech-steps.ts` to confirm the change clears `MIN_OVERALL_F1`.
*
* Usage:
*
* pnpm --filter api exec tsx src/scripts/calibrate-tech-step-threshold.ts
*/
async function calibrateTechStepThreshold(): Promise<void> {
console.info(`Classifying ${TECH_STEP_EVAL_DATASET.length} eval case(s)...`);
// One classifier pass per eval case, all clauses' raw verdicts kept
// alongside the case's own `expectedKeys` — reused for every candidate
// threshold in the loop below.
const casesWithClauses = await Promise.all(
TECH_STEP_EVAL_DATASET.map(async (evalCase) => ({
expectedKeys: evalCase.expectedKeys,
clauses: await techStepClassifier.classifyClauses(evalCase.description, evalCase.locale),
})),
);
console.info("\nthreshold precision recall f1");
let bestThreshold = CANDIDATE_THRESHOLDS[0] ?? 0;
let bestF1 = -1;
for (const threshold of CANDIDATE_THRESHOLDS) {
const outcomes: TechStepEvalOutcome[] = casesWithClauses.map(({ expectedKeys, clauses }) => {
const actualKeys = clauses
// Mirrors `_classifyClause`'s own decision rule exactly (see that
// method, `tech-step-matcher.ts`) — the classifier's own verdict
// when confident enough, otherwise its clause's NER anchor, `null`
// when neither applies (no keyword, no confident classification).
.map((clause) =>
clause.intentUid !== null && clause.score >= threshold
? clause.intentUid
: clause.anchorUid,
)
.filter((key): key is string => key !== null);
return { expectedKeys, actualKeys };
});
const { overall } = computeTechStepMetrics(outcomes);
console.info(
`${threshold.toFixed(2)} ${overall.precision.toFixed(3)} ${overall.recall.toFixed(3)} ${overall.f1.toFixed(3)}`,
);
if (overall.f1 > bestF1) {
bestF1 = overall.f1;
bestThreshold = threshold;
}
}
console.info(
`\nBest aggregate F1 ${bestF1.toFixed(3)} at threshold ${bestThreshold.toFixed(2)} — update CONFIDENCE_THRESHOLD in tech-step-matcher.ts by hand if this differs from the current value.`,
);
}
calibrateTechStepThreshold()
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err);
await prisma.$disconnect();
process.exit(1);
});

View file

@ -0,0 +1,72 @@
import { prisma } from "../db/prisma.js";
/**
* Maintainer-facing report of every `TechStepTrainingSuggestion` still
* `status: "pending"` (`TechStepTrainingSuggestion`'s own schema doc
* comment) generated by `services/tech-step-llm-worker`'s scheduled
* jobs, from either a user correction or the worker's own low-confidence
* audit (`sourceType`). What a maintainer reads *before* hand-editing
* `services/tech-step-intent-service/intent_service/training_data.py` and
* running `retrain-tech-steps.ts` this script never writes anything,
* purely a read-only report to stdout:
*
* pnpm --filter api exec tsx src/scripts/list-pending-training-suggestions.ts
*
* Grouped by technique key so every suggestion for the same entry in
* `training_data.py`'s `TECH_STEP_TRAINING_DATA` is read together, matching
* how that file itself is organized (one block per technique).
*/
async function listPendingTrainingSuggestions(): Promise<void> {
const suggestions = await prisma.techStepTrainingSuggestion.findMany({
where: { status: "pending" },
orderBy: [{ techStepId: "asc" }, { createdAt: "asc" }],
include: { techStep: { select: { key: true } } },
});
if (suggestions.length === 0) {
console.info("No pending training suggestions.");
return;
}
const byTechStepKey = new Map<string, typeof suggestions>();
for (const suggestion of suggestions) {
const key = suggestion.techStep.key;
const group = byTechStepKey.get(key);
if (group) {
group.push(suggestion);
} else {
byTechStepKey.set(key, [suggestion]);
}
}
const lines: string[] = [`# Pending tech-step training suggestions (${suggestions.length})`, ""];
for (const [techStepKey, group] of byTechStepKey) {
lines.push(`## ${techStepKey}`, "");
for (const suggestion of group) {
const source =
suggestion.sourceCorrectionId !== null
? `${suggestion.sourceType} (correction #${suggestion.sourceCorrectionId})`
: suggestion.sourceType;
lines.push(`- id ${suggestion.id} · locale ${suggestion.locale} · source: ${source}`);
if (suggestion.suggestedSynonyms.length > 0) {
lines.push(` - synonyms: ${suggestion.suggestedSynonyms.join(", ")}`);
}
if (suggestion.suggestedUtterances.length > 0) {
lines.push(
` - utterances: ${suggestion.suggestedUtterances.map((u) => `"${u}"`).join(", ")}`,
);
}
}
lines.push("");
}
console.info(lines.join("\n"));
}
listPendingTrainingSuggestions()
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err);
await prisma.$disconnect();
process.exit(1);
});

View file

@ -0,0 +1,103 @@
import { prisma } from "../db/prisma.js";
import {
MIN_OVERALL_F1,
runTechStepEvalSuite,
} from "../lib/recipe-matching/tech-step-eval-runner.js";
import { backfillTechSteps } from "./backfill-tech-steps.js";
/** Parses `--applied=1,2,3`/`--rejected=4,5` from argv into id arrays — both optional, both empty by default (a run with neither flag only re-gates + backfills, doesn't touch any suggestion's status). */
function parseSuggestionIds(flag: "applied" | "rejected"): number[] {
const prefix = `--${flag}=`;
const arg = process.argv.find((value) => value.startsWith(prefix));
if (arg === undefined) return [];
return arg
.slice(prefix.length)
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0)
.map((value) => {
const id = Number(value);
if (!Number.isInteger(id)) {
throw new Error(`--${flag}: "${value}" is not a valid integer id`);
}
return id;
});
}
/**
* Maintainer workflow closing the loop on a training-corpus change (see
* this feature's plan document):
*
* 1. A maintainer has already hand-edited
* `services/tech-step-intent-service/intent_service/training_data.py`
* (informed by `list-pending-training-suggestions.ts`'s report),
* decided which `TechStepTrainingSuggestion` ids they incorporated
* (`--applied=`) or explicitly discarded (`--rejected=`), **and
* restarted `tech-step-intent-service`** so it retrains from the
* edited corpus that service only ever trains once, at its own
* startup (see its README), so this script's eval gate below is
* meaningless against a service still running the old corpus.
* 2. This script re-runs the F1 regression gate
* ({@link runTechStepEvalSuite} against {@link MIN_OVERALL_F1})
* refuses to backfill at all if the edited corpus scores worse than
* the floor, so a bad edit never reaches every existing recipe.
* 3. Backfills every `Step`'s `StepTechStep` sequence against the new
* corpus ({@link backfillTechSteps}).
* 4. Marks the given suggestion ids `applied`/`rejected`, so
* `list-pending-training-suggestions.ts`'s next report doesn't
* surface them again.
*
* Usage:
*
* pnpm --filter api exec tsx src/scripts/retrain-tech-steps.ts --applied=12,13 --rejected=14
*
* `--applied`/`--rejected` are both optional omitting both still runs
* the gate + backfill, just leaves every suggestion's `status` untouched
* (useful for re-running the backfill alone after a corpus edit made with
* no suggestions involved at all).
*/
async function retrainTechSteps(): Promise<void> {
const appliedIds = parseSuggestionIds("applied");
const rejectedIds = parseSuggestionIds("rejected");
console.info("Evaluating the current classifier against the labeled evaluation set...");
const { overall } = await runTechStepEvalSuite();
console.info(
`F1 ${overall.f1.toFixed(3)} (precision ${overall.precision.toFixed(3)}, recall ${overall.recall.toFixed(3)})`,
);
if (overall.f1 < MIN_OVERALL_F1) {
throw new Error(
`Aggregate F1 ${overall.f1.toFixed(3)} is below the ${MIN_OVERALL_F1} regression floor — refusing to backfill. Revert or fix the corpus change and re-run.`,
);
}
const { total, changed } = await backfillTechSteps();
console.info(`Backfilled ${changed}/${total} step(s).`);
if (appliedIds.length > 0) {
// `updateMany`'s own `count` (rows actually matched/updated), not
// `appliedIds.length` (what was merely *asked for*) — an id that
// doesn't exist (typo, already-processed id) would otherwise log a
// success count that silently doesn't match what really changed.
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
where: { id: { in: appliedIds } },
data: { status: "applied" },
});
console.info(`Marked ${count}/${appliedIds.length} suggestion(s) as applied.`);
}
if (rejectedIds.length > 0) {
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
where: { id: { in: rejectedIds } },
data: { status: "rejected" },
});
console.info(`Marked ${count}/${rejectedIds.length} suggestion(s) as rejected.`);
}
}
retrainTechSteps()
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err);
await prisma.$disconnect();
process.exit(1);
});

View file

@ -9,21 +9,44 @@ import { registerAllRecipeSources } from "./sources/index.js";
// doesn't happen inside app.ts/createServer() itself. // doesn't happen inside app.ts/createServer() itself.
registerAllRecipeSources(); registerAllRecipeSources();
// Trains the tech-step classifier (and pays node-nlp's own one-time lazy /**
// setup cost — see `TechStepClassifierService.warmUp`) before accepting * Trains the tech-step classifier (a `POST /v1/train` round-trip per locale
// any traffic, so the first real recipe save/preview isn't the one stuck * to `services/tech-step-intent-service` see
// waiting several seconds for it. * `TechStepClassifierService.warmUp`) before accepting any traffic, so the
* first real recipe save/preview isn't the one stuck waiting for it.
*
* Retried with exponential backoff: in Docker Compose, `app`'s own
* `depends_on: tech-step-intent-service: condition: service_healthy`
* (`docker-compose.yml`) already means that service is up by the time this
* runs, but native dev (`pnpm dev:api`, no Compose ordering at all) can
* easily start this before the intent service has finished loading its
* spaCy models a transient connection failure here shouldn't need a
* manual restart. Still non-fatal after every attempt is exhausted: the
* *next* real call retries training itself (see `_ensureTrained`'s own
* retry-on-failure comment), same graceful-degrade posture as before this
* retry loop existed.
*/
async function warmUpTechStepClassifier(): Promise<void> {
const maxAttempts = 5;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try { try {
await techStepClassifier.warmUp(); await techStepClassifier.warmUp();
return;
} catch (err) { } catch (err) {
// Not fatal to startup — a failed warm-up just means the *next* call if (attempt === maxAttempts) {
// retries training itself (see `_ensureTrained`'s own retry-on-failure logger.error("Tech-step classifier warm-up failed after retries", {
// comment), same graceful-degrade posture as everywhere else training
// failures surface. Still worth a loud log: this shouldn't normally fail.
logger.error("Tech-step classifier warm-up failed", {
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
attempts: attempt,
}); });
return;
} }
const delayMs = 1000 * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
await warmUpTechStepClassifier();
const server = createServer(); const server = createServer();

View file

@ -0,0 +1,425 @@
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListItem,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
import { jsonLdRecipeAdapter } from "./json-ld-recipe.js";
const SOURCE_KEY = "750g";
// 750g.com's own site search is a client-side widget (results are fetched
// by the page's own JS after load, nothing server-rendered to scrape) — but
// that JS itself calls this plain GET endpoint, an "AI answer engine" that
// returns an HTML fragment of recipe cards for a free-text query. Verified
// live: works with a bare `fetch`, no special headers/cookies/session
// needed, same as every other adapter in this family. Only used for a
// non-empty query — see `LATEST_RECIPES_URL` for why: this endpoint answers
// a blank query with nothing at all.
const SEARCH_URL = "https://www.750g.com/genius/query/";
// What `list()` reads instead of `SEARCH_URL` for an empty/omitted `query`
// ("browse everything", per `RecipeSourceListParams.query`'s own doc
// comment) — verified live, `SEARCH_URL` responds to a blank query with a
// zero-length body, so browsing this source with no filter typed would
// otherwise always come back empty. `dernieres-recettes.htm` is 750g.com's
// own "latest recipes" archive: real, server-rendered pagination via
// `&page=N` (unlike `SEARCH_URL`, which doesn't paginate at all — see
// `list()`'s own comment on `nextCursor`), same `card-recipe`/`card-link`
// markup `extractRecipeCards` already reads elsewhere on the site. Checked
// live up to `page=500` — genuinely different recipes every time, no
// redirect/clamp once past whatever the real end is (unlike marmiton.ts's
// search, which 404s past its last page), so `list()` treats a page with no
// cards at all as the end-of-results signal instead.
const LATEST_RECIPES_URL = "https://www.750g.com/dernieres-recettes.htm";
/**
* Matches every `<script type="application/ld+json">…</script>` block
* same shape as `JSON_LD_SCRIPT_PATTERN` in json-ld-recipe.ts, kept as its
* own private copy here rather than sharing that module's export: this one
* does textual surgery on the *raw HTML* before `jsonLdRecipeAdapter` ever
* sees it (see {@link sanitizeJsonLdBlocks} below), a different concern
* from extracting-and-parsing blocks into objects.
*/
const JSON_LD_SCRIPT_PATTERN =
/(<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>)([\s\S]*?)(<\/script>)/gi;
/**
* Escapes any raw (unescaped) JSON control character U+0000U+001F
* found *inside* a string literal of `json`, leaving everything outside
* string literals (structural whitespace, brackets, ) untouched. Fixes a
* real bug in 750g.com's own JSON-LD generator: some `HowToStep.text`
* values contain a literal, un-escaped `\r\n` where valid JSON requires
* `\\r\\n` (verified live, e.g.
* https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm — and
* roughly a third of a random sample of recipe pages hit this)
* `JSON.parse` throws "Bad control character in string literal" on these
* pages as-is, which would make `jsonLdRecipeAdapter.parse` wrongly report
* "no JSON-LD Recipe found" on a page that has a perfectly good one.
*
* A blind find/replace across the whole block would be wrong: JSON also
* uses real newlines as *structural* whitespace between tokens
* (pretty-printing), where they're perfectly legal and must be left alone
* only walking the text with string-literal awareness (tracking `"…"`
* boundaries and `\`-escapes) can tell the two apart.
*/
function escapeRawControlCharactersInStrings(json: string): string {
const SHORT_ESCAPES: Record<string, string> = {
"\b": "\\b",
"\f": "\\f",
"\n": "\\n",
"\r": "\\r",
"\t": "\\t",
};
let result = "";
let inString = false;
let escapedNext = false;
for (const ch of json) {
if (!inString) {
if (ch === '"') inString = true;
result += ch;
continue;
}
if (escapedNext) {
result += ch;
escapedNext = false;
continue;
}
if (ch === "\\") {
result += ch;
escapedNext = true;
continue;
}
if (ch === '"') {
inString = false;
result += ch;
continue;
}
if (ch < " ") {
result += SHORT_ESCAPES[ch] ?? `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`;
continue;
}
result += ch;
}
return result;
}
/**
* Runs {@link escapeRawControlCharactersInStrings} over every JSON-LD
* `<script>` block's content in `html`, leaving the rest of the page
* untouched the repair step `fetchDetail`'s raw HTML needs before
* `jsonLdRecipeAdapter.parse` (which does its own extraction/`JSON.parse`
* internally) ever sees it.
*/
function sanitizeJsonLdBlocks(html: string): string {
return html.replace(
JSON_LD_SCRIPT_PATTERN,
(_match, openTag: string, json: string, closeTag: string) =>
`${openTag}${escapeRawControlCharactersInStrings(json)}${closeTag}`,
);
}
/**
* Numeric entities plus a hand-picked table of named ones not a general
* HTML5 entity decoder (400+ named entities exist in the spec), just what's
* actually been observed necessary to clean up 750g.com's French recipe
* text: the five basic XML entities, Latin-1 accented letters, and a
* handful of common punctuation entities.
*/
const NAMED_ENTITIES: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
eacute: "é",
egrave: "è",
ecirc: "ê",
euml: "ë",
agrave: "à",
acirc: "â",
auml: "ä",
icirc: "î",
iuml: "ï",
ocirc: "ô",
ouml: "ö",
ucirc: "û",
ugrave: "ù",
uuml: "ü",
ccedil: "ç",
oelig: "œ",
aelig: "æ",
laquo: "«",
raquo: "»",
lsquo: "",
rsquo: "",
ldquo: "“",
rdquo: "”",
hellip: "…",
ndash: "",
mdash: "—",
deg: "°",
};
/** One pass of numeric (`&#39;`/`&#x27;`) and {@link NAMED_ENTITIES} decoding — see {@link decodeHtmlEntities}, which is what actually runs against parsed text; this is split out only so that function can run it twice. */
function decodeHtmlEntitiesOnce(text: string): string {
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (entity, body: string) => {
if (body[0] === "#") {
const isHex = body[1] === "x" || body[1] === "X";
const codePoint = isHex
? Number.parseInt(body.slice(2), 16)
: Number.parseInt(body.slice(1), 10);
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
}
return NAMED_ENTITIES[body] ?? entity;
});
}
/**
* Decodes HTML entities in free-text pulled from 750g.com, run **twice**:
* its JSON-LD sometimes double-escapes text that already went through its
* own HTML-entity encoder once e.g. a real "é" ends up as `&amp;eacute;`
* (the `&` of an already-produced `&eacute;` got re-escaped to `&amp;`)
* rather than a plain `&eacute;` or a raw "é" (verified live, e.g.
* "Pr&amp;eacute;parez" on
* https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm). One
* pass turns that into `&eacute;` a *newly formed*, valid-looking entity
* so a second pass is needed to resolve it the rest of the way to "é". A
* string with no entities at all (the common case) is unaffected by either
* pass.
*/
function decodeHtmlEntities(text: string): string {
return decodeHtmlEntitiesOnce(decodeHtmlEntitiesOnce(text));
}
/**
* Runs {@link decodeHtmlEntities} over every free-text field of a
* `ParsedRecipe` produced by `jsonLdRecipeAdapter.parse`. `picture`/
* `sourceUrl` are deliberately left untouched they're URLs, not prose,
* and the entity-encoding bug this fixes has only ever been observed in
* name/description/instruction/ingredient text, never in a URL field.
*/
function decodeParsedRecipeText(recipe: ParsedRecipe): ParsedRecipe {
return {
...recipe,
name: decodeHtmlEntities(recipe.name),
description: recipe.description === null ? null : decodeHtmlEntities(recipe.description),
ingredients: recipe.ingredients.map((ingredient) => ({
...ingredient,
rawText: decodeHtmlEntities(ingredient.rawText),
name: decodeHtmlEntities(ingredient.name),
})),
steps: recipe.steps.map((step) => ({
...step,
description: decodeHtmlEntities(step.description),
})),
};
}
/** One recipe card as scraped off a 750g.com results fragment (search or listing page) — see {@link extractRecipeCards}. */
interface SevenFiftyGCard {
url: string;
title: string;
image: string | null;
}
/**
* Scrapes every recipe card out of a 750g.com results HTML fragment
* there's no JSON-LD `ItemList` on this endpoint to lean on (unlike
* marmiton.ts's search page), just the same server-rendered `card-recipe`
* markup 750g.com uses everywhere. Each card's title/url comes from its
* `<a class="card-link">`; its image is whichever `<img>` most recently
* preceded that link, rather than a naive same-index zip of "every image on
* the page" against "every link on the page" a plain fragment like this
* one carries a few extra decorative images with no card of their own
* (verified live: 28 `<img>` tags against 23 real cards for one sample
* query), which would silently shift every image after the first stray one
* onto the wrong title. Each card's own `<img>` always sits immediately
* before its title link in the markup, so "nearest preceding image" is
* unambiguous and doesn't depend on the two counts matching.
*/
function extractRecipeCards(html: string): SevenFiftyGCard[] {
const linkPattern =
/<a\s+href="(https:\/\/www\.750g\.com\/[^"]+)"\s+class="card-link[^"]*">([^<]+)<\/a>/g;
const imagePattern = /<img[^>]*\ssrc="(https:\/\/static\.750g\.com\/images\/[^"]+)"[^>]*>/g;
const images = [...html.matchAll(imagePattern)];
const cards: SevenFiftyGCard[] = [];
let searchFrom = 0;
for (const linkMatch of html.matchAll(linkPattern)) {
let image: string | null = null;
for (const imgMatch of images) {
if (imgMatch.index === undefined || imgMatch.index >= linkMatch.index) break;
if (imgMatch.index >= searchFrom) image = imgMatch[1] ?? null;
}
cards.push({
url: linkMatch[1] ?? "",
title: decodeHtmlEntities(linkMatch[2] ?? ""),
image,
});
searchFrom = linkMatch.index;
}
return cards;
}
/**
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having
* come from this adapter instead (`sourceKey` `"750g"`) same reasoning as
* marmiton.ts's identically-named helper: `fetchDetail`/`parse` below are
* thin wrappers around the generic adapter's own methods, but a caller
* catching `RecipeSourceError` and reading `.sourceKey` should see "750g",
* the source it actually asked about.
*/
function rekeySourceError(err: unknown): unknown {
if (err instanceof RecipeSourceFetchError) {
return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause });
}
if (err instanceof RecipeSourceParseError) {
return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause });
}
return err;
}
/**
* 750g.com one of France's largest recipe sites. Unofficial (`official:
* false`): no published API, this adapter fetches ordinary pages and reads
* the schema.org structured data 750g.com embeds for search engines, built
* on {@link jsonLdRecipeAdapter} the same way marmiton.ts is. Two real
* 750g-specific problems separate this adapter from a pure thin wrapper
* like marmiton.ts, though:
*
* - `list()` has no `ItemList` JSON-LD to read off its search results (see
* {@link extractRecipeCards}) its site search is a client-side widget,
* so a non-empty query instead calls the plain GET endpoint that widget's
* own JS calls internally (`SEARCH_URL`), an "AI answer engine" that
* returns a curated batch of cards rather than an exhaustive, paginated
* catalog verified live, requesting `page=2` of the same query always
* comes back empty, so `nextCursor` is always `null` in that case, same
* as `theMealDbAdapter`'s "one response holds every match". An empty
* query reads `LATEST_RECIPES_URL` instead, a real paginated catalog
* `SEARCH_URL` itself answers a blank query with nothing at all, which
* would otherwise make browsing this source with no filter typed always
* come back empty.
* - `parse()` doesn't delegate to `jsonLdRecipeAdapter.parse` as directly as
* marmiton.ts's does — 750g.com's own JSON-LD generator has two real bugs
* this adapter works around: some pages embed literal, unescaped control
* characters inside a JSON string (see {@link sanitizeJsonLdBlocks}), and
* its free text is sometimes double HTML-entity-encoded (see
* {@link decodeParsedRecipeText}). Both are pre/post-processing around the
* same underlying delegation, not a reimplementation of it.
*/
export const sevenFiftyGAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
key: SOURCE_KEY,
name: "750g",
official: false,
// Un chemin stable, sans le paramètre `?v=…` de cache-busting que 750g.com
// ajoute à ses balises <link> (susceptible de changer à chaque
// déploiement) — cette adresse répond correctement sans lui.
iconUrl: "https://www.750g.com/img/750g/favicons/favicon.svg",
// Le contenu de 750g.com (noms, ingrédients, instructions) est en
// français — détermine contre quel modèle/locale d'étiquettes
// d'ingrédients translateRecipe (recipe-translation.ts) résout les
// recettes de cette source lors d'une prévisualisation/d'un import.
locale: "fr",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
const query = params.query ?? "";
const page = params.cursor ? Number(params.cursor) : 1;
const hasQuery = query.length > 0;
// Deux endpoints distincts selon qu'il y a un texte de recherche ou
// non — voir les commentaires de `SEARCH_URL`/`LATEST_RECIPES_URL` :
// le premier ne répond rien du tout à une requête vide, le second est
// le vrai catalogue paginé "dernières recettes" de 750g.com. `page`
// n'a de sens que pour le second (le premier ne pagine pas — voir
// plus bas) mais est toujours passé, y compris `page=1`, par
// cohérence avec le reste de cette famille d'adaptateurs.
const listUrl = hasQuery
? `${SEARCH_URL}?query=${encodeURIComponent(query)}&query_type=written_query&page=1`
: `${LATEST_RECIPES_URL}?page=${page}`;
let response: Response;
try {
response = await fetch(listUrl);
} catch (cause) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Network error listing 750g recipes (${listUrl})`,
{
cause,
},
);
}
if (!response.ok) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`750g responded ${response.status} (${listUrl})`,
);
}
const html = await response.text();
// No filter for a missing title/url here (unlike marmiton.ts/
// the-meal-db.ts, which drop entries with a null field from a
// structured API response) — `extractRecipeCards`' own regex requires
// at least one character for both, so there's no "absent field" shape
// to guard against.
const items: RecipeSourceListItem[] = extractRecipeCards(html).map((card) => ({
externalId: card.url,
title: card.title,
picture: card.image,
url: card.url,
}));
// La recherche par texte libre ne pagine pas du tout (voir le
// commentaire de `SEARCH_URL`) — `nextCursor` y vaut toujours `null`,
// même logique que `theMealDbAdapter`. "Dernières recettes" pagine
// réellement (voir le commentaire de `LATEST_RECIPES_URL`) — une page
// sans aucune carte en est le signal de fin.
const nextCursor = hasQuery ? null : items.length > 0 ? String(page + 1) : null;
return { items, nextCursor };
} catch (err) {
// Rethrown as-is (already keyed "750g" by whichever branch above
// threw it) — this adapter's only caller (`sources.service.ts`)
// already handles/logs failures centrally; this method just isn't
// allowed a bare `async` body without a try/catch per the repo's
// convention. Same reasoning as `marmiton.ts`/`json-ld-recipe.ts`.
throw err;
}
},
// `externalId` est directement l'URL canonique de la recette sur
// 750g.com (renvoyée telle quelle par `list()` ci-dessus) — même
// convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
// délègue entièrement : la réparation du JSON-LD (voir
// `sanitizeJsonLdBlocks`) n'a lieu qu'à l'étape `parse()`, pas ici.
async fetchDetail(externalId: string): Promise<{ html: string; url: string }> {
try {
return await jsonLdRecipeAdapter.fetchDetail(externalId);
} catch (err) {
// Pas un simple re-throw : `rekeySourceError` est le traitement utile
// que ce point d'appel doit faire de l'erreur (relabelliser sa
// `sourceKey`), conformément à la convention await/try-catch du repo.
throw rekeySourceError(err);
}
},
parse(raw: { html: string; url: string }): ParsedRecipe {
try {
const sanitizedHtml = sanitizeJsonLdBlocks(raw.html);
const parsed = jsonLdRecipeAdapter.parse({ html: sanitizedHtml, url: raw.url });
return decodeParsedRecipeText(parsed);
} catch (err) {
throw rekeySourceError(err);
}
},
};

View file

@ -1,12 +1,15 @@
import { registerRecipeSource } from "../lib/recipe-sources/recipe-source-registry.js"; import { registerRecipeSource } from "../lib/recipe-sources/recipe-source-registry.js";
import { sevenFiftyGAdapter } from "./750g.js";
import { mangerBougerAdapter } from "./manger-bouger.js";
import { marmitonAdapter } from "./marmiton.js";
import { theMealDbAdapter } from "./the-meal-db.js"; import { theMealDbAdapter } from "./the-meal-db.js";
/** /**
* Registers every concrete, *browsable* `RecipeSourceAdapter` this app * Registers every concrete, *browsable* `RecipeSourceAdapter` this app
* ships with into the shared in-memory registry * ships with into the shared in-memory registry (`recipe-source-registry.ts`)
* (`recipe-source-registry.ts`) currently just `theMealDbAdapter`. * `theMealDbAdapter`, `marmitonAdapter`, `sevenFiftyGAdapter` and
* Called once, explicitly, by the two real entry points that need the * `mangerBougerAdapter`. Called once, explicitly, by the two real entry
* registry populated: * points that need the registry populated:
* *
* - `server.ts` the running API process, before it starts listening. * - `server.ts` the running API process, before it starts listening.
* - `prisma/seed.ts` so `syncRecipeSources` has something to mirror into * - `prisma/seed.ts` so `syncRecipeSources` has something to mirror into
@ -21,15 +24,19 @@ import { theMealDbAdapter } from "./the-meal-db.js";
* explicit setup. Tests that need a source in the registry register their * explicit setup. Tests that need a source in the registry register their
* own throwaway fake instead (see e.g. `test/recipe-source-sync.test.ts`). * own throwaway fake instead (see e.g. `test/recipe-source-sync.test.ts`).
* *
* `jsonLdRecipeAdapter` (json-ld-recipe.ts) is deliberately **not** * `jsonLdRecipeAdapter` (json-ld-recipe.ts) itself is deliberately **not**
* registered here it's a generic schema.org-JSON-LD parser meant to be * registered here it's a generic schema.org-JSON-LD parser meant to be
* specialized per scraped website (a concrete adapter for a specific site * specialized per scraped website, not a household-toggleable `Source` in
* would use it internally), not a household-toggleable `Source` in its own * its own right: nobody can meaningfully "trust" or "enable" a generic
* right: nobody can meaningfully "trust" or "enable" a generic parsing * parsing mechanism the way they can a named website. `marmitonAdapter`
* mechanism the way they can a named website. Until real per-site adapters * (marmiton.ts), `sevenFiftyGAdapter` (750g.ts) and `mangerBougerAdapter`
* exist, it's called directly (e.g. a future "import from a pasted URL" * (manger-bouger.ts) are exactly that specialization, one per site the
* flow), never through this registry. * concrete adapters its own doc comment anticipated ("a concrete adapter
* for a specific site would use it internally").
*/ */
export function registerAllRecipeSources(): void { export function registerAllRecipeSources(): void {
registerRecipeSource(theMealDbAdapter); registerRecipeSource(theMealDbAdapter);
registerRecipeSource(marmitonAdapter);
registerRecipeSource(sevenFiftyGAdapter);
registerRecipeSource(mangerBougerAdapter);
} }

View file

@ -44,8 +44,17 @@ interface SchemaOrgRecipe {
url?: string; url?: string;
} }
/** Extracts and JSON-parses every JSON-LD block on the page — a block that fails to parse is skipped rather than failing the whole page over one malformed script tag (some sites ship more than one JSON-LD block, e.g. `BreadcrumbList` alongside `Recipe`). */ /**
function extractJsonLdBlocks(html: string): unknown[] { * Extracts and JSON-parses every JSON-LD block on the page a block that
* fails to parse is skipped rather than failing the whole page over one
* malformed script tag (some sites ship more than one JSON-LD block, e.g.
* `BreadcrumbList` alongside `Recipe`). Exported (not just consumed
* internally by {@link findRecipeNode} below) so a concrete per-site adapter
* built on top of this module e.g. `marmiton.ts`, which needs the same
* page's embedded `ItemList` rather than its `Recipe` reuses this same
* extraction step instead of re-implementing the `<script>`-block regex.
*/
export function extractJsonLdBlocks(html: string): unknown[] {
const blocks: unknown[] = []; const blocks: unknown[] = [];
for (const match of html.matchAll(JSON_LD_SCRIPT_PATTERN)) { for (const match of html.matchAll(JSON_LD_SCRIPT_PATTERN)) {
try { try {
@ -167,6 +176,17 @@ function flattenInstructions(instructions: SchemaOrgRecipe["recipeInstructions"]
* `fetchDetail`'s `externalId` is simply the target URL itself, not an id * `fetchDetail`'s `externalId` is simply the target URL itself, not an id
* from a prior `list()` call. A future "import from URL" flow would call * from a prior `list()` call. A future "import from URL" flow would call
* `fetchDetail(pastedUrl)` directly. * `fetchDetail(pastedUrl)` directly.
*
* `marmiton.ts`'s `marmitonAdapter` is the first concrete adapter built on
* top of this one its `fetchDetail`/`parse` delegate straight here (a
* marmiton.org recipe page's JSON-LD is a plain schema.org `Recipe`, nothing
* site-specific to handle), and it only adds the `list()` this adapter
* itself can't offer, by reading the separate `ItemList` marmiton.org embeds
* on its search-results pages. `750g.ts`'s `sevenFiftyGAdapter` and
* `manger-bouger.ts`'s `mangerBougerAdapter` follow the same shape for their
* own sites, but each wraps this adapter's own `parse()` (rather than
* delegating untouched) to work around real bugs/gaps in that site's own
* JSON-LD see each module's doc comment.
*/ */
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ export const jsonLdRecipeAdapter: RecipeSourceAdapter<{
html: string; html: string;

View file

@ -0,0 +1,355 @@
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListItem,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
import { jsonLdRecipeAdapter } from "./json-ld-recipe.js";
const SOURCE_KEY = "mangerBouger";
// "La Fabrique à Menus" — mangerbouger.fr's recipe tool (Santé publique
// France). Its listing page is a Next.js app with no JSON-LD `ItemList` at
// all (unlike marmiton.ts's search page) — but it's server-rendered, and a
// plain GET carries the exact same Redux state the client hydrates from as
// a `__NEXT_DATA__` script tag (see `extractNextData` below), which already
// has everything `list()` needs. Verified live: `?query=<free text>` really
// filters server-side (not just a client-side URL update over an
// already-fetched page), and `page`/`hasMorePages` behave as real,
// consistent pagination — the best-behaved of this adapter family's three
// sources on that front.
const LIST_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes";
const DETAIL_BASE_URL = "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes/";
/** Matches the `<script id="__NEXT_DATA__">…</script>` block every Next.js page ships — the site's own server-rendered hydration data, read instead of scraping HTML for both `list()` (the listing's recipe cards) and `parse()` (backfilling a gap in the detail page's JSON-LD, see {@link extractPortionsFromNextData}). */
const NEXT_DATA_PATTERN = /<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/;
/** The one field of one `list[]` entry `list()` actually reads off the listing page's `__NEXT_DATA__` — that state carries the site's full internal `Recipe` shape (60+ fields: nutriscore, seasons, macros, …), none of which this adapter's contract has anywhere to put. */
interface MangerBougerListEntry {
slug?: string;
name?: string;
image?: string | null;
}
/** The slice of `__NEXT_DATA__` this module reads off the *listing* page. */
interface MangerBougerListPageData {
props?: {
initialState?: {
recipes?: {
list?: MangerBougerListEntry[];
/** Whether a further page exists for the current `page`/`query`/`diet` combination — verified live: an out-of-range page comes back `false` with an empty `list` rather than repeating the last page or erroring, a cleaner end-of-results signal than either `marmiton.ts` (infers it from a 404) or `750g.ts` (this search has no real pagination at all). */
hasMorePages?: boolean;
};
};
};
}
/** The slice of `__NEXT_DATA__` this module reads off a recipe *detail* page — a different shape than the listing page's (`initialState.recipe.recipe`, not `initialState.recipes.list[]`) since it's a different Redux slice entirely. */
interface MangerBougerDetailPageData {
props?: {
initialState?: {
recipe?: {
recipe?: {
portions?: unknown;
};
};
};
};
}
/** Parses the page's `__NEXT_DATA__` block into `T`, or `null` if the block is missing or isn't valid JSON — callers degrade gracefully rather than throw, same as `marmiton.ts`'s "page has no ItemList at all" handling. */
function extractNextData<T>(html: string): T | null {
const match = html.match(NEXT_DATA_PATTERN);
if (!match) return null;
try {
return JSON.parse(match[1] ?? "") as T;
} catch {
return null;
}
}
function detailUrl(slug: string): string {
return `${DETAIL_BASE_URL}${slug}`;
}
/**
* Matches the single `<script type="application/ld+json">…</script>` block
* a mangerbouger.fr recipe *detail* page carries (verified live across a
* sample of 9 recipes always exactly one, always a bare `Recipe`, never
* an `@graph`) a much narrower pattern than `json-ld-recipe.ts`'s own
* `JSON_LD_SCRIPT_PATTERN` (no `g` flag: this module only ever needs the
* first/only block, to patch it see {@link patchRecipeJsonLd}) or
* `750g.ts`'s identically-named private copy (which does its own,
* different, character-level repair over every block on the page).
*/
const JSON_LD_SCRIPT_PATTERN =
/(<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>)([\s\S]*?)(<\/script>)/i;
/** One node of a Slate.js rich-text document — see {@link flattenSlateDocument}. */
interface SlateNode {
type?: string;
text?: string;
children?: SlateNode[];
}
/** Concatenates a run of inline Slate nodes (leaf text, or further-nested inline runs) with no separator — bold/italic/underline marks (the only ones observed) carry no plain-text equivalent and are simply dropped. */
function flattenSlateInline(nodes: SlateNode[]): string {
return nodes
.map((node) =>
typeof node.text === "string"
? node.text
: node.children
? flattenSlateInline(node.children)
: "",
)
.join("");
}
/**
* Flattens a Slate.js document's top-level blocks into one line of plain
* text each verified live across every recipe step sampled (72 recipes):
* only `paragraph` and `bulleted-list` (of `list-item`s) ever appear as
* block types, so that's all this handles; any other/unrecognized block
* type still degrades reasonably (its own children read as one inline run)
* rather than being dropped outright.
*/
function flattenSlateBlocks(nodes: SlateNode[]): string[] {
const lines: string[] = [];
for (const node of nodes) {
if (node.type === "bulleted-list" && node.children) {
lines.push(...flattenSlateBlocks(node.children));
continue;
}
if (node.type === "list-item" && node.children) {
const text = flattenSlateInline(node.children);
if (text.trim().length > 0) lines.push(`- ${text}`);
continue;
}
if (node.children) {
const text = flattenSlateInline(node.children);
if (text.trim().length > 0) lines.push(text);
continue;
}
if (typeof node.text === "string" && node.text.trim().length > 0) lines.push(node.text);
}
return lines;
}
/**
* Flattens one `HowToStep.text` value into plain text. mangerbouger.fr's
* own JSON-LD embeds this field pre-formatted for its own web app instead
* of as prose: `text` is itself a JSON-serialized Slate.js rich-text
* document (verified live: every one of 72 sampled recipe steps parses as
* one) handing that straight to `jsonLdRecipeAdapter.parse` would surface
* the raw `[{"type":"paragraph","children":[{"text":"…` blob as a step's
* description, unusable as-is. `json` that doesn't parse as an array (a
* genuinely plain-text step, or some future/different shape) is returned
* unchanged rather than mangled.
*/
function flattenSlateDocument(json: string): string {
let doc: unknown;
try {
doc = JSON.parse(json);
} catch {
return json;
}
if (!Array.isArray(doc)) return json;
return flattenSlateBlocks(doc as SlateNode[]).join("\n");
}
/** The two schema.org `Recipe` fields {@link patchRecipeJsonLd} patches, plus an index signature so every other field survives re-serialization untouched. */
interface JsonLdRecipeLike {
recipeInstructions?: unknown;
recipeYield?: unknown;
[key: string]: unknown;
}
/** One `HowToStep`-shaped entry of `recipeInstructions`, as far as {@link patchRecipeJsonLd} needs to know. */
interface JsonLdHowToStepLike {
text?: unknown;
[key: string]: unknown;
}
/**
* `state.recipe.recipe.portions` from the same detail page's `__NEXT_DATA__`
* the number `recipeYield` should have been (see {@link patchRecipeJsonLd}),
* read from the site's own internal state rather than left unstated.
*/
function extractPortionsFromNextData(html: string): number | null {
const data = extractNextData<MangerBougerDetailPageData>(html);
const portions = data?.props?.initialState?.recipe?.recipe?.portions;
return typeof portions === "number" ? portions : null;
}
/**
* Repairs the two real gaps verified live in mangerbouger.fr's own
* recipe-detail JSON-LD, then hands the patched HTML to
* `jsonLdRecipeAdapter.parse` unmodified otherwise same "fix what's
* actually broken, delegate the rest" shape as `750g.ts`'s
* `sanitizeJsonLdBlocks`/`decodeParsedRecipeText`, just structural (parse
* mutate re-serialize the one JSON-LD object) rather than textual, since
* both gaps need real understanding of the document, not character-level
* fixups:
*
* - `recipeInstructions[].text` is Slate.js rich text, not prose flattened
* via {@link flattenSlateDocument}.
* - `recipeYield` is absent on every one of 9 sampled recipes (schema.org
* allows omitting it, and mangerbouger.fr's generator apparently always
* does) even though the site's own internal data has the serving count
* right there backfilled from `__NEXT_DATA__` via
* {@link extractPortionsFromNextData} rather than left as a needless
* `portions: null` on every single imported recipe.
*
* A missing or malformed JSON-LD block is left completely untouched
* `jsonLdRecipeAdapter`'s own "no JSON-LD Recipe found"/"malformed block,
* skip it" handling is exactly the right behavior for that, no need to
* duplicate it here.
*/
function patchRecipeJsonLd(html: string): string {
const match = html.match(JSON_LD_SCRIPT_PATTERN);
if (!match) return html;
let recipe: JsonLdRecipeLike;
try {
recipe = JSON.parse(match[2] ?? "{}") as JsonLdRecipeLike;
} catch {
return html;
}
if (Array.isArray(recipe.recipeInstructions)) {
for (const step of recipe.recipeInstructions as JsonLdHowToStepLike[]) {
if (step && typeof step === "object" && typeof step.text === "string") {
step.text = flattenSlateDocument(step.text);
}
}
}
if (recipe.recipeYield === undefined) {
const portions = extractPortionsFromNextData(html);
if (portions !== null) recipe.recipeYield = portions;
}
const patchedJson = JSON.stringify(recipe);
return html.replace(
JSON_LD_SCRIPT_PATTERN,
(_full, openTag: string, _json: string, closeTag: string) =>
`${openTag}${patchedJson}${closeTag}`,
);
}
/**
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having
* come from this adapter instead (`sourceKey` `"mangerBouger"`) same
* reasoning as `marmiton.ts`/`750g.ts`'s identically-named helpers.
*/
function rekeySourceError(err: unknown): unknown {
if (err instanceof RecipeSourceFetchError) {
return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause });
}
if (err instanceof RecipeSourceParseError) {
return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause });
}
return err;
}
/**
* mangerbouger.fr ("La Fabrique à Menus") Santé publique France's public
* nutrition site. Unofficial (`official: false`): no published API, same
* reasoning as every other adapter in this family fetching ordinary pages
* and reading data the site never committed to a stable contract, not a
* maintained endpoint. `fetchDetail` delegates straight to
* `jsonLdRecipeAdapter`; `parse` wraps it with {@link patchRecipeJsonLd}
* (see that function's doc comment for the two real gaps it fixes).
* `list()` doesn't use JSON-LD at all — see `LIST_URL`'s doc comment.
*/
export const mangerBougerAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
key: SOURCE_KEY,
name: "Manger Bouger",
official: false,
// Chemin fixe (pas d'icône versionnée/hashée comme sur d'autres sources
// de cette famille) — répond correctement sans paramètre supplémentaire.
iconUrl: "https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/favicon.ico",
// Le contenu de mangerbouger.fr (noms, ingrédients, instructions) est en
// français — détermine contre quel modèle/locale d'étiquettes
// d'ingrédients translateRecipe (recipe-translation.ts) résout les
// recettes de cette source lors d'une prévisualisation/d'un import.
locale: "fr",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
const page = params.cursor ? Number(params.cursor) : 1;
const query = params.query ?? "";
const listUrl = `${LIST_URL}?diet=ALL&page=${page}&query=${encodeURIComponent(query)}`;
let response: Response;
try {
response = await fetch(listUrl);
} catch (cause) {
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error listing recipes (${listUrl})`, {
cause,
});
}
if (!response.ok) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`mangerbouger.fr responded ${response.status} (${listUrl})`,
);
}
const html = await response.text();
const data = extractNextData<MangerBougerListPageData>(html);
const state = data?.props?.initialState?.recipes;
const items: RecipeSourceListItem[] = (state?.list ?? [])
.filter((entry): entry is MangerBougerListEntry & { slug: string; name: string } =>
Boolean(entry.slug && entry.name),
)
.map((entry) => ({
externalId: detailUrl(entry.slug),
title: entry.name,
picture: entry.image ?? null,
url: detailUrl(entry.slug),
}));
return { items, nextCursor: state?.hasMorePages ? String(page + 1) : null };
} catch (err) {
// Rethrown as-is (already keyed "mangerBouger" by whichever branch
// above threw it) — this adapter's only caller (`sources.service.ts`)
// already handles/logs failures centrally; this method just isn't
// allowed a bare `async` body without a try/catch per the repo's
// convention. Same reasoning as `marmiton.ts`/`750g.ts`.
throw err;
}
},
// `externalId` est directement l'URL canonique de la recette sur
// mangerbouger.fr (renvoyée telle quelle par `list()` ci-dessus) — même
// convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
// délègue entièrement : la réparation du JSON-LD (voir
// `patchRecipeJsonLd`) n'a lieu qu'à l'étape `parse()`, pas ici.
async fetchDetail(externalId: string): Promise<{ html: string; url: string }> {
try {
return await jsonLdRecipeAdapter.fetchDetail(externalId);
} catch (err) {
// Pas un simple re-throw : `rekeySourceError` est le traitement utile
// que ce point d'appel doit faire de l'erreur (relabelliser sa
// `sourceKey`), conformément à la convention await/try-catch du repo.
throw rekeySourceError(err);
}
},
parse(raw: { html: string; url: string }): ParsedRecipe {
try {
const patchedHtml = patchRecipeJsonLd(raw.html);
return jsonLdRecipeAdapter.parse({ html: patchedHtml, url: raw.url });
} catch (err) {
throw rekeySourceError(err);
}
},
};

View file

@ -0,0 +1,206 @@
import type {
ParsedRecipe,
RecipeSourceAdapter,
RecipeSourceListItem,
RecipeSourceListParams,
RecipeSourceListResult,
} from "../lib/recipe-sources/recipe-source-adapter.js";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../lib/recipe-sources/recipe-source-errors.js";
import { extractJsonLdBlocks, jsonLdRecipeAdapter } from "./json-ld-recipe.js";
const SOURCE_KEY = "marmiton";
const SEARCH_URL = "https://www.marmiton.org/recettes/recherche.aspx";
/**
* One `ListItem` inside the schema.org `ItemList` marmiton.org embeds as
* JSON-LD on its search-results pages the subset this adapter reads. Also
* what a search whose term happens to match a known ingredient (e.g.
* `aqt=poulet`) actually returns: marmiton.org silently serves its
* ingredient-index page instead of a "search results" page for those terms,
* but that page embeds the exact same `ItemList` shape, so `list()` doesn't
* need to tell the two apart.
*/
interface MarmitonListItem {
"@type"?: string;
url?: string;
name?: string;
image?: string;
}
/** The subset of a schema.org `ItemList` this adapter reads off marmiton.org's search-results page. */
interface MarmitonItemList {
"@type"?: string;
"@graph"?: unknown[];
itemListElement?: MarmitonListItem[];
}
/**
* Finds the first `ItemList` node within one parsed JSON-LD block mirrors
* `findRecipeNode`'s traversal in json-ld-recipe.ts (array of mixed-type
* nodes, `@graph` wrapper) but looks for the results listing marmiton.org's
* search page embeds instead of a `Recipe`.
*/
function findItemListNode(node: unknown): MarmitonItemList | null {
if (node === null || typeof node !== "object") return null;
if (Array.isArray(node)) {
for (const item of node) {
const found = findItemListNode(item);
if (found) return found;
}
return null;
}
const obj = node as MarmitonItemList;
if (obj["@type"] === "ItemList") return obj;
if (Array.isArray(obj["@graph"])) return findItemListNode(obj["@graph"]);
return null;
}
/**
* Re-labels a `RecipeSourceFetchError`/`RecipeSourceParseError` thrown by
* the generic `jsonLdRecipeAdapter` (`sourceKey` `"jsonLdRecipe"`) as having
* come from this adapter instead (`sourceKey` `"marmiton"`). `fetchDetail`/
* `parse` below are thin wrappers around the generic adapter's own methods
* (see this module's doc comment) but a caller catching `RecipeSourceError`
* and reading `.sourceKey` to attribute a failure to a specific `Source`
* should see "marmiton", the source it actually asked about, not the
* internal implementation detail this adapter happens to be built on.
* Anything else (a bug, an unexpected throw) is passed through unchanged
* only the vocabulary this module documents gets relabeled.
*/
function rekeySourceError(err: unknown): unknown {
if (err instanceof RecipeSourceFetchError) {
return new RecipeSourceFetchError(SOURCE_KEY, err.message, { cause: err.cause });
}
if (err instanceof RecipeSourceParseError) {
return new RecipeSourceParseError(SOURCE_KEY, err.message, { cause: err.cause });
}
return err;
}
/**
* marmiton.org France's largest recipe site. Unofficial (`official:
* false`): there's no published API, this adapter fetches ordinary pages and
* reads the schema.org structured data marmiton.org embeds for search
* engines, same as {@link jsonLdRecipeAdapter} it's built on. It's the first
* concrete, per-site adapter that generic adapter's own doc comment
* anticipated ("a concrete adapter for a specific site would use it
* internally") `fetchDetail`/`parse` below just delegate straight to it,
* since a marmiton.org recipe page's JSON-LD is a plain schema.org `Recipe`
* with nothing site-specific to handle. The only real Marmiton-specific
* logic is `list()`: `jsonLdRecipeAdapter` has no catalog of its own to
* browse, but marmiton.org's search-results page embeds a browsable
* `ItemList` this adapter reads directly (see {@link findItemListNode}).
*/
export const marmitonAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
key: SOURCE_KEY,
name: "Marmiton",
official: false,
// Un chemin stable (jamais un nom de fichier avec un hash de build, comme
// les icônes servies depuis statics.marmiton.fr) — marmiton.org sert son
// favicon à cette adresse indépendamment de tout déploiement.
iconUrl: "https://www.marmiton.org/favicon.ico",
// Le contenu de Marmiton (noms, ingrédients, instructions) est en
// français — détermine contre quel modèle/locale d'étiquettes
// d'ingrédients translateRecipe (recipe-translation.ts) résout les
// recettes de cette source lors d'une prévisualisation/d'un import.
locale: "fr",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
// Un curseur opaque qui encode simplement le numéro de page suivant —
// marmiton.org pagine sa recherche via `&page=N` (page 1 implicite
// quand le paramètre est absent), pas de token dédié à faire
// transiter.
const page = params.cursor ? Number(params.cursor) : 1;
const query = params.query ?? "";
const searchUrl = `${SEARCH_URL}?aqt=${encodeURIComponent(query)}${
page > 1 ? `&page=${page}` : ""
}`;
let response: Response;
try {
response = await fetch(searchUrl);
} catch (cause) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Network error searching Marmiton (${searchUrl})`,
{ cause },
);
}
// marmiton.org répond 404 dès que `page` dépasse la dernière page de
// résultats pour cette recherche — pas un vrai échec, juste "il n'y a
// plus rien" : son `ItemList` ne porte aucun total fiable (son
// `numberOfItems` vaut toujours la taille de la page courante, jamais
// le nombre total de résultats) pour le détecter à l'avance autrement
// qu'en demandant la page suivante et en constatant qu'elle est vide.
if (response.status === 404) {
return { items: [], nextCursor: null };
}
if (!response.ok) {
throw new RecipeSourceFetchError(
SOURCE_KEY,
`Marmiton search responded ${response.status} (${searchUrl})`,
);
}
const html = await response.text();
let itemList: MarmitonItemList | null = null;
for (const block of extractJsonLdBlocks(html)) {
itemList = findItemListNode(block);
if (itemList) break;
}
const items: RecipeSourceListItem[] = (itemList?.itemListElement ?? [])
.filter((entry): entry is MarmitonListItem & { url: string; name: string } =>
Boolean(entry.url && entry.name),
)
.map((entry) => ({
externalId: entry.url,
title: entry.name,
picture: entry.image ?? null,
url: entry.url,
}));
return {
items,
// Voir le commentaire ci-dessus sur la réponse 404 : une page vide
// est elle-même le signal de fin, donc on ne propose une page
// suivante que si celle-ci en a retourné au moins un résultat.
nextCursor: items.length > 0 ? String(page + 1) : null,
};
} catch (err) {
// Rethrown as-is (already keyed "marmiton" by whichever branch above
// threw it) — this adapter's only caller (`sources.service.ts`)
// already handles/logs failures centrally; this method just isn't
// allowed a bare `async` body without a try/catch per the repo's
// convention. Same reasoning as `json-ld-recipe.ts`/`the-meal-db.ts`.
throw err;
}
},
// `externalId` est directement l'URL canonique de la recette sur
// marmiton.org (renvoyée telle quelle par `list()` ci-dessus) — même
// convention que `jsonLdRecipeAdapter.fetchDetail`, à qui cette méthode
// délègue entièrement (voir le commentaire du module).
async fetchDetail(externalId: string): Promise<{ html: string; url: string }> {
try {
return await jsonLdRecipeAdapter.fetchDetail(externalId);
} catch (err) {
// Pas un simple re-throw : `rekeySourceError` est le traitement utile
// que ce point d'appel doit faire de l'erreur (relabelliser sa
// `sourceKey`), conformément à la convention await/try-catch du repo.
throw rekeySourceError(err);
}
},
parse(raw: { html: string; url: string }): ParsedRecipe {
try {
return jsonLdRecipeAdapter.parse(raw);
} catch (err) {
throw rekeySourceError(err);
}
},
};

View file

@ -1,50 +0,0 @@
/**
* Minimal ambient typing for `node-nlp` (no official/DefinitelyTyped types
* exist for it) declares only the `NlpManager` surface
* `tech-step-matcher.ts` actually calls, verified against the real
* package (v4.27.0) rather than the library's full documented API, which
* this repo doesn't use the rest of.
*/
declare module "node-nlp" {
/** Constructor options this repo passes — `NlpManager` accepts more, only what's used here is typed. */
export interface NlpManagerOptions {
languages?: string[];
forceNER?: boolean;
nlu?: { log?: boolean };
ner?: { threshold?: number };
/** Defaults to `true` — persists the trained model to `modelFileName` (default `model.nlp`, in `process.cwd()`). See `tech-step-matcher.ts`'s own constructor comment for why this repo always sets it `false`. */
autoSave?: boolean;
/** Defaults to `true` — loads from `modelFileName` instead of training fresh if that file already exists. Always `false` here, same reasoning as `autoSave`. */
autoLoad?: boolean;
}
/** One entity `NlpManager.process`'s result reports — see `tech-step-matcher.ts`'s own `NerEntity` for the subset this repo reads. */
export interface NlpEntity {
entity: string;
start: number;
end: number;
type: string;
accuracy?: number;
sourceText?: string;
}
/** `NlpManager.process`'s result — trimmed to the fields this repo reads (the real object carries many more). */
export interface NlpProcessResult {
intent: string;
score: number;
entities: NlpEntity[];
}
export class NlpManager {
public constructor(options?: NlpManagerOptions);
public addNamedEntityText(
entityName: string,
optionName: string,
languages: string[],
texts: string[],
): void;
public addDocument(locale: string, utterance: string, intent: string): void;
public train(): Promise<void>;
public process(locale: string, text: string): Promise<NlpProcessResult>;
}
}

View file

@ -0,0 +1,40 @@
import { techStepClassifier } from "../src/lib/recipe-matching/tech-step-matcher.js";
import { resetDatabase } from "./reset-db.js";
/**
* Mocha root hook plugin (see `.mocharc.json`'s `require`) runs once
* before every test file's own suites, regardless of load order.
*
* Warms up `techStepClassifier` here resolving the `TechStep.key -> id`
* lookup from the DB (see `TechStepClassifierService._loadTechStepIds`)
* instead of leaving it to happen lazily on whichever test file Mocha
* happens to load first, same as `server.ts` does before the real server
* ever accepts traffic. Fast by itself (one DB query, one HTTP call to
* `services/tech-step-intent-service`): that service now trains itself
* entirely at its own process startup (see its own README), so unlike
* before this migration, nothing here waits on a slow training pass CI's
* own "wait for `/health`" step (`.github/workflows/ci.yml`) is what
* ensures that service is already fully trained before `pnpm --filter api
* test` even starts.
*
* `resetDatabase()` runs first, deliberately: id resolution needs
* `TechStep` rows, and a freshly-migrated (never-seeded) test database has
* none yet. Every per-test `beforeEach` in this suite already calls
* `resetDatabase()` again before its own test, which is a no-op
* duplication of effort but not a correctness problem: `TRUNCATE ...
* RESTART IDENTITY` plus deterministic re-seeding (`seedReferenceData`)
* assigns the exact same ids every time, so the `uid -> id` map memoized
* here from this first reset stays valid for every reset after it.
*/
export const mochaHooks = {
// biome-ignore lint/suspicious/noExplicitAny: Mocha's root hook `this` (a Context with `.timeout()`) isn't typed without @types/mocha (not a dependency here) — same untyped-`this` shape already used in tech-step-worker.routes.test.ts.
async beforeAll(this: any): Promise<void> {
// A little more generous than Mocha's normal 10s per-test default
// (`.mocharc.json`) purely for a slower/contended CI runner's first
// network round-trip to `services/tech-step-intent-service` — not
// because anything here waits on training anymore.
this.timeout(30000);
await resetDatabase();
await techStepClassifier.warmUp();
},
};

View file

@ -0,0 +1,306 @@
import { ErrorCode } from "@batch-cooking/shared";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../../src/app.js";
import { env } from "../../src/config/env.js";
import { prisma } from "../../src/db/prisma.js";
import { resetDatabase } from "../../test-support/reset-db.js";
const SECRET_HEADER = "X-Internal-Worker-Secret";
/** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — mirrors `recipe.test.ts`'s own `techStepId` helper. */
async function techStepId(key: string): Promise<number> {
const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } });
return techStep.id;
}
/** A minimal author + recipe + step fixture — these routes have no notion of a session/viewer, so nothing here needs to go through `/auth/signup` the way `recipe.test.ts`'s fixtures do. */
async function createRecipeWithStep(
description = "Faire mijoter la sauce.",
): Promise<{ stepId: number; recipeId: number }> {
const author = await prisma.userProfile.create({
data: {
firstName: "Test",
lastName: "Author",
email: `${crypto.randomUUID()}@example.test`,
passwordHash: "not-a-real-hash",
},
});
const recipe = await prisma.recipe.create({
data: {
name: "Recette",
authorId: author.id,
portions: 4,
steps: { create: [{ description, order: 0 }] },
},
include: { steps: true },
});
const step = recipe.steps[0];
if (!step) throw new Error("expected the fixture recipe to have one step");
return { stepId: step.id, recipeId: recipe.id };
}
describe("Internal tech-step worker routes", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("requireInternalWorker", () => {
it("rejects a request with no secret header with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/internal/tech-steps/audit-batch");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a request with the wrong secret with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app)
.get("/internal/tech-steps/audit-batch")
.set(SECRET_HEADER, "definitely-not-the-right-secret");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects even the correct secret with 401 NOT_AUTHENTICATED on a plain user-facing route (no bypass of requireAuth)", async () => {
const res = await request(app)
.get("/recipes")
.query({ tab: "publique" })
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET ?? "irrelevant-unset-in-this-env");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
});
// Every test below needs a real configured secret to exercise the success
// path — skipped (not failed) in an environment that hasn't set one, same
// "optional, but the surface fails closed without it" posture
// `INTERNAL_WORKER_SECRET` itself has (see config/env.ts). Both this
// repo's `.env.test.example` and `.github/workflows/ci.yml` set one, so
// this only actually skips in an environment that deliberately diverges
// from both.
describe("with a configured secret", () => {
before(function skipWithoutConfiguredSecret() {
if (env.INTERNAL_WORKER_SECRET === undefined) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed without @types/mocha (not a dependency here) — same untyped-`this` shape a plain JS mocha callback would have.
(this as any).skip();
}
});
function withSecret(req: request.Test): request.Test {
return req.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string);
}
describe("GET /internal/tech-steps/audit-batch", () => {
// A true low-confidence positive case can't be asserted here without
// a live trained classifier to verify the exact sentence against
// first — same limitation `tech-step-eval-dataset.ts` documents for
// the same reason (no local Postgres was reachable in the session
// that introduced this file). This test instead covers the
// deterministic negative: a step the classifier confidently resolves
// (proven by `tech-step-matcher.test.ts`'s own identical-sentence
// case) must produce zero audit entries — nothing here should ever
// flag a confident match as worth a second opinion.
it("finds nothing to audit in a step the classifier confidently resolves", async () => {
await createRecipeWithStep("Faire mijoter à feu doux");
const res = await withSecret(
request(app).get("/internal/tech-steps/audit-batch").query({ locale: "fr" }),
);
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
it("finds nothing to audit in a step naming no technique at all", async () => {
await createRecipeWithStep("Ranger les couverts dans le tiroir");
const res = await withSecret(
request(app).get("/internal/tech-steps/audit-batch").query({ locale: "fr" }),
);
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
it("rejects a non-positive limit with 400 VALIDATION_ERROR", async () => {
const res = await withSecret(
request(app).get("/internal/tech-steps/audit-batch").query({ limit: 0 }),
);
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
});
describe("GET /internal/tech-steps/pending-corrections", () => {
it("returns unconsumed corrections, oldest first, excluding already-consumed ones", async () => {
const { stepId, recipeId } = await createRecipeWithStep();
const simmerId = await techStepId("simmer");
const author = await prisma.recipe
.findUniqueOrThrow({ where: { id: recipeId } })
.then((recipe) => recipe.authorId);
const older = await prisma.stepTechStepCorrection.create({
data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
});
const consumed = await prisma.stepTechStepCorrection.create({
data: {
stepId,
correctorId: author,
start: 0,
end: 5,
correctedTechStepId: simmerId,
consumedAt: new Date(),
},
});
const newer = await prisma.stepTechStepCorrection.create({
data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
});
const res = await withSecret(request(app).get("/internal/tech-steps/pending-corrections"));
expect(res.status).to.equal(200);
const ids = (res.body as Array<{ id: number }>).map((entry) => entry.id);
expect(ids).to.deep.equal([older.id, newer.id]);
expect(ids).to.not.include(consumed.id);
});
it("respects ?limit=", async () => {
const { stepId, recipeId } = await createRecipeWithStep();
const simmerId = await techStepId("simmer");
const author = await prisma.recipe
.findUniqueOrThrow({ where: { id: recipeId } })
.then((recipe) => recipe.authorId);
await prisma.stepTechStepCorrection.createMany({
data: [
{ stepId, correctorId: author, start: 0, end: 5, correctedTechStepId: simmerId },
{ stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
],
});
const res = await withSecret(
request(app).get("/internal/tech-steps/pending-corrections").query({ limit: 1 }),
);
expect(res.status).to.equal(200);
expect(res.body).to.have.length(1);
});
});
describe("POST /internal/tech-steps/training-suggestions", () => {
it("creates a suggestion and marks its source correction consumed", async () => {
const { stepId, recipeId } = await createRecipeWithStep();
const simmerId = await techStepId("simmer");
const author = await prisma.recipe
.findUniqueOrThrow({ where: { id: recipeId } })
.then((recipe) => recipe.authorId);
const correction = await prisma.stepTechStepCorrection.create({
data: { stepId, correctorId: author, start: 6, end: 13, correctedTechStepId: simmerId },
});
const res = await withSecret(
request(app)
.post("/internal/tech-steps/training-suggestions")
.send({
suggestions: [
{
techStepKey: "simmer",
locale: "fr",
suggestedSynonyms: ["frémissonner"],
suggestedUtterances: ["laisser frémissonner à feu très doux"],
sourceType: "correction",
sourceCorrectionId: correction.id,
},
],
}),
);
expect(res.status).to.equal(201);
expect(res.body).to.deep.equal({ created: 1 });
const suggestions = await prisma.techStepTrainingSuggestion.findMany({
where: { techStepId: simmerId },
});
expect(suggestions).to.have.length(1);
expect(suggestions[0]?.sourceCorrectionId).to.equal(correction.id);
expect(suggestions[0]?.status).to.equal("pending");
const updatedCorrection = await prisma.stepTechStepCorrection.findUniqueOrThrow({
where: { id: correction.id },
});
expect(updatedCorrection.consumedAt).to.not.equal(null);
});
it("accepts an llm_audit suggestion with no sourceCorrectionId", async () => {
const res = await withSecret(
request(app)
.post("/internal/tech-steps/training-suggestions")
.send({
suggestions: [
{
techStepKey: "boil",
locale: "fr",
suggestedSynonyms: ["bouillonner"],
suggestedUtterances: [],
sourceType: "llm_audit",
},
],
}),
);
expect(res.status).to.equal(201);
expect(res.body).to.deep.equal({ created: 1 });
});
it("rejects sourceType 'correction' with no sourceCorrectionId with 400 VALIDATION_ERROR", async () => {
const res = await withSecret(
request(app)
.post("/internal/tech-steps/training-suggestions")
.send({
suggestions: [
{
techStepKey: "boil",
locale: "fr",
suggestedSynonyms: ["bouillonner"],
suggestedUtterances: [],
sourceType: "correction",
},
],
}),
);
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects an unknown techStepKey with 404 TECH_STEP_NOT_FOUND", async () => {
const res = await withSecret(
request(app)
.post("/internal/tech-steps/training-suggestions")
.send({
suggestions: [
{
techStepKey: "not-a-real-tech-step",
locale: "fr",
suggestedSynonyms: [],
suggestedUtterances: [],
sourceType: "llm_audit",
},
],
}),
);
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND);
});
});
});
});

View file

@ -1,8 +1,9 @@
import { INGREDIENT_LABEL_SYNONYMS_EN } from "@batch-cooking/shared"; import { INGREDIENT_LABEL_SYNONYMS_EN, INGREDIENT_LABEL_SYNONYMS_FR } from "@batch-cooking/shared";
import { expect } from "chai"; import { expect } from "chai";
import { prisma } from "../../src/db/prisma.js"; import { prisma } from "../../src/db/prisma.js";
import { import {
extractQuantity, extractQuantity,
findIngredientMentions,
type IngredientMatchEntry, type IngredientMatchEntry,
loadIngredientCatalog, loadIngredientCatalog,
loadUnitCatalog, loadUnitCatalog,
@ -90,6 +91,48 @@ describe("ingredient-matcher", () => {
const onionB: IngredientMatchEntry = { ingredientId: 21, label: "Onion" }; const onionB: IngredientMatchEntry = { ingredientId: 21, label: "Onion" };
expect(matchIngredientName("onion", [onionB, onionA])).to.equal(20); expect(matchIngredientName("onion", [onionB, onionA])).to.equal(20);
}); });
describe("locale: fr", () => {
const carotte: IngredientMatchEntry = { ingredientId: 30, label: "Carotte" };
const poulet: IngredientMatchEntry = { ingredientId: 31, label: "Poulet" };
const blancDePoulet: IngredientMatchEntry = { ingredientId: 32, label: "Blanc de poulet" };
const frCatalog = [carotte, poulet, blancDePoulet];
it("tolerates a regular French plural (a bare 's', unlike English's several suffix patterns)", () => {
// Regression case: French plurals like "carottes" end in "es", which
// the English stemmer's own "es" rule would wrongly strip down to
// "carott" (losing the "e" that's part of the singular "carotte")
// — see stemWordFr's own doc comment. Locale "fr" must use the
// French stemmer instead, or this never matches.
expect(matchIngredientName("carottes", frCatalog, "fr")).to.equal(carotte.ingredientId);
});
it("is accent-insensitive the same way the English path is", () => {
expect(matchIngredientName("CAROTTES", frCatalog, "fr")).to.equal(carotte.ingredientId);
});
it("tolerates extra descriptive words around the match", () => {
expect(matchIngredientName("2 carottes râpées", frCatalog, "fr")).to.equal(
carotte.ingredientId,
);
});
it("prefers the more specific multi-word label over a shorter one it contains", () => {
expect(matchIngredientName("blancs de poulet fermier", frCatalog, "fr")).to.equal(
blancDePoulet.ingredientId,
);
});
it("defaults to the English stemmer when no locale is passed — 'fr' text needs to opt in explicitly", () => {
// Without locale: "fr", "carottes" stems via the English rules
// (endsWith("es") -> strip 2 chars) into "carott", which doesn't
// equal the catalog's own (also English-stemmed) "carotte" — no
// match. This is the exact bug locale-aware stemming fixes; this
// test pins down that the *default* stays exactly as it was for
// every pre-existing English-only caller.
expect(matchIngredientName("carottes", frCatalog)).to.equal(null);
});
});
}); });
describe("matchUnit", () => { describe("matchUnit", () => {
@ -117,10 +160,14 @@ describe("ingredient-matcher", () => {
expect(matchUnit("TBSP", catalog)).to.equal(tablespoon.unitId); expect(matchUnit("TBSP", catalog)).to.equal(tablespoon.unitId);
}); });
it("only looks at the first word — ignores trailing text", () => { it("ignores trailing text after the unit word", () => {
expect(matchUnit("cup flour", catalog)).to.equal(cup.unitId); expect(matchUnit("cup flour", catalog)).to.equal(cup.unitId);
}); });
it("also finds the unit word when it isn't first — unlike before French support existed, this is no longer only a first-word check (see the function's own doc comment)", () => {
expect(matchUnit("a heaped tablespoon of sugar", catalog)).to.equal(tablespoon.unitId);
});
it("doesn't match a short abbreviation inside an unrelated word", () => { it("doesn't match a short abbreviation inside an unrelated word", () => {
// "g" alone must not match "grated" — whole-token comparison. // "g" alone must not match "grated" — whole-token comparison.
expect(matchUnit("grated", catalog)).to.equal(null); expect(matchUnit("grated", catalog)).to.equal(null);
@ -137,6 +184,35 @@ describe("ingredient-matcher", () => {
it("returns null for an empty string", () => { it("returns null for an empty string", () => {
expect(matchUnit("", catalog)).to.equal(null); expect(matchUnit("", catalog)).to.equal(null);
}); });
describe("locale: fr", () => {
const gramme: UnitMatchEntry = { unitId: 40, synonyms: ["g", "gr", "gramme", "grammes"] };
const cuillereASoupe: UnitMatchEntry = {
unitId: 41,
synonyms: ["cuillère à soupe", "cuillères à soupe", "càs"],
};
const frCatalog = [gramme, cuillereASoupe];
it("matches a genuinely multi-word synonym — the bug this locale support fixes: the old single-first-token check could never equal a whole multi-word phrase", () => {
expect(matchUnit("cuillères à soupe de farine", frCatalog, "fr")).to.equal(
cuillereASoupe.unitId,
);
});
it("matches a single-word abbreviation the same way English units do", () => {
expect(matchUnit("càs de farine", frCatalog, "fr")).to.equal(cuillereASoupe.unitId);
});
it("is accent-insensitive", () => {
expect(matchUnit("2 CUILLÈRES À SOUPE de farine", frCatalog, "fr")).to.equal(
cuillereASoupe.unitId,
);
});
it("doesn't match a multi-word phrase against unrelated text mentioning the same first word alone", () => {
expect(matchUnit("cuillère de bois", frCatalog, "fr")).to.equal(null);
});
});
}); });
describe("extractQuantity", () => { describe("extractQuantity", () => {
@ -191,6 +267,100 @@ describe("ingredient-matcher", () => {
}); });
}); });
describe("findIngredientMentions", () => {
const butter: IngredientMatchEntry = { ingredientId: 1, label: "Butter" };
const flour: IngredientMatchEntry = { ingredientId: 2, label: "Flour" };
const egg: IngredientMatchEntry = { ingredientId: 3, label: "Egg" };
const catalog = [butter, flour, egg];
const gram: UnitMatchEntry = { unitId: 1, synonyms: ["g", "gram", "grams"] };
const unitCatalog = [gram];
it("finds a single mention with no quantity or unit", () => {
const text = "melt the butter";
const mentions = findIngredientMentions(text, catalog, unitCatalog);
expect(mentions).to.have.length(1);
const [mention] = mentions;
expect(mention?.ingredientId).to.equal(butter.ingredientId);
expect(text.slice(mention?.start, mention?.end)).to.equal("butter");
expect(mention?.quantity).to.equal(null);
expect(mention?.unitId).to.equal(null);
});
it('resolves a quantity and unit glued directly to the ingredient ("200g butter")', () => {
const text = "add 200g butter";
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
expect(mention?.ingredientId).to.equal(butter.ingredientId);
expect(mention?.quantity).to.equal(200);
expect(mention?.unitId).to.equal(gram.unitId);
});
it("finds several mentions in reading order, non-overlapping", () => {
const text = "melt the butter then add the flour and an egg";
const mentions = findIngredientMentions(text, catalog, unitCatalog);
expect(mentions.map((mention) => mention.ingredientId)).to.deep.equal([
butter.ingredientId,
flour.ingredientId,
egg.ingredientId,
]);
});
it("is case- and accent-insensitive", () => {
const text = "MELT THE BUTTER";
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
expect(mention?.ingredientId).to.equal(butter.ingredientId);
});
it("ignores an unrelated number earlier in the text (e.g. an oven temperature)", () => {
const text = "preheat to 180 degrees then add the egg";
const [mention] = findIngredientMentions(text, catalog, unitCatalog);
expect(mention?.ingredientId).to.equal(egg.ingredientId);
expect(mention?.quantity).to.equal(null);
});
it("returns an empty array when nothing in the catalog is mentioned", () => {
expect(findIngredientMentions("stir well", catalog, unitCatalog)).to.deep.equal([]);
});
it("returns an empty array for empty text", () => {
expect(findIngredientMentions("", catalog, unitCatalog)).to.deep.equal([]);
});
describe("locale: fr", () => {
const beurre: IngredientMatchEntry = { ingredientId: 10, label: "Beurre" };
const farine: IngredientMatchEntry = { ingredientId: 11, label: "Farine" };
const frCatalog = [beurre, farine];
const gramme: UnitMatchEntry = { unitId: 40, synonyms: ["g", "gr", "gramme", "grammes"] };
const cuillereASoupe: UnitMatchEntry = {
unitId: 41,
synonyms: ["cuillère à soupe", "cuillères à soupe", "càs"],
};
const frUnitCatalog = [gramme, cuillereASoupe];
it("resolves a quantity and unit before the ingredient, connected by 'de'", () => {
const text = "faire fondre 50g de beurre";
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
expect(mention?.ingredientId).to.equal(beurre.ingredientId);
expect(mention?.quantity).to.equal(50);
expect(mention?.unitId).to.equal(gramme.unitId);
expect(text.slice(mention?.start, mention?.end)).to.equal("beurre");
});
it('resolves a multi-word unit connected by "d\'"', () => {
const text = "ajouter 2 cuillères à soupe de farine";
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
expect(mention?.ingredientId).to.equal(farine.ingredientId);
expect(mention?.quantity).to.equal(2);
expect(mention?.unitId).to.equal(cuillereASoupe.unitId);
});
it("is accent-insensitive", () => {
const text = "FAIRE FONDRE LE BEURRE";
const [mention] = findIngredientMentions(text, frCatalog, frUnitCatalog, "fr");
expect(mention?.ingredientId).to.equal(beurre.ingredientId);
});
});
});
describe("loadIngredientCatalog / loadUnitCatalog", () => { describe("loadIngredientCatalog / loadUnitCatalog", () => {
beforeEach(async () => { beforeEach(async () => {
await resetDatabase(); await resetDatabase();
@ -239,5 +409,51 @@ describe("ingredient-matcher", () => {
const cupEntry = catalog.find((entry) => entry.unitId === cup.id); const cupEntry = catalog.find((entry) => entry.unitId === cup.id);
expect(cupEntry?.synonyms).to.deep.equal(["cup", "cups"]); expect(cupEntry?.synonyms).to.deep.equal(["cup", "cups"]);
}); });
it("loads one entry per Ingredient that has a French label, plus one per alternate wording (INGREDIENT_LABEL_SYNONYMS_FR), keyed by real ingredientId", async () => {
const carrot = await prisma.ingredient.findFirstOrThrow({ where: { key: "carrot" } });
const vanillaBean = await prisma.ingredient.findFirstOrThrow({
where: { key: "vanillaBean" },
});
const ingredientCount = await prisma.ingredient.count();
const synonymCount = Object.values(INGREDIENT_LABEL_SYNONYMS_FR).reduce(
(sum, synonyms) => sum + synonyms.length,
0,
);
const catalog = await loadIngredientCatalog("fr");
// Every seeded ingredient has an authored French label too (copied
// from apps/web's fr locale — see catalog-labels-fr.ts's own doc
// comment), so this mirrors the English test above 1:1.
expect(catalog).to.have.length(ingredientCount + synonymCount);
const carrotEntry = catalog.find((entry) => entry.ingredientId === carrot.id);
expect(carrotEntry?.label).to.equal("Carotte");
const vanillaBeanEntries = catalog.filter((entry) => entry.ingredientId === vanillaBean.id);
expect(vanillaBeanEntries.map((entry) => entry.label)).to.deep.equal([
"Vanille (gousse)",
"Gousse de vanille",
]);
});
it("loads one entry per Unit that has French synonyms, keyed by real unitId", async () => {
const cup = await prisma.unit.findFirstOrThrow({ where: { key: "cup" } });
const unitCount = await prisma.unit.count();
const catalog = await loadUnitCatalog("fr");
expect(catalog).to.have.length(unitCount);
const cupEntry = catalog.find((entry) => entry.unitId === cup.id);
expect(cupEntry?.synonyms).to.deep.equal(["tasse", "tasses"]);
});
it("returns an empty catalog for a locale with no label table at all — the DB is still queried, there's just nothing in either table to match a row against", async () => {
const ingredientCatalog = await loadIngredientCatalog("de");
const unitCatalog = await loadUnitCatalog("de");
expect(ingredientCatalog).to.deep.equal([]);
expect(unitCatalog).to.deep.equal([]);
});
}); });
}); });

View file

@ -38,8 +38,9 @@ describe("recipe-translation", () => {
// `translateRecipeSteps` now goes through `techStepClassifier` (a // `translateRecipeSteps` now goes through `techStepClassifier` (a
// trained model, not a pure regex test against a caller-supplied // trained model, not a pure regex test against a caller-supplied
// mapping list — see `tech-step-matcher.ts`), so these tests exercise // mapping list — see `tech-step-matcher.ts`), so these tests exercise
// the real training corpus (`tech-step-training-data.ts`) against a real // the real training corpus (`services/tech-step-intent-service`'s
// `TechStep` catalog rather than synthetic fixtures — same posture // `training_data.py`) against a real `TechStep` catalog rather than
// synthetic fixtures — same posture
// `tech-step-matcher.test.ts`'s own `techStepClassifier` describe block // `tech-step-matcher.test.ts`'s own `techStepClassifier` describe block
// takes, for the same reason. // takes, for the same reason.
describe("translateRecipeSteps", () => { describe("translateRecipeSteps", () => {
@ -453,7 +454,47 @@ describe("recipe-translation", () => {
]); ]);
}); });
it("leaves ingredients untouched (no quantity extraction either) for a non-English locale — no matching data exists yet, and the DB isn't even queried for it", async () => { it("resolves a real Ingredient id from the seeded French catalog, tolerating a regular French plural", async () => {
const carrot = await prisma.ingredient.findFirstOrThrow({ where: { key: "carrot" } });
const recipe: ParsedRecipe = {
...buildParsedRecipe(["Faire mijoter à feu doux"]),
ingredients: [{ rawText: "3 carottes", quantity: null, unit: null, name: "carottes" }],
};
const translated = await translateRecipe(recipe, "fr");
expect(translated.ingredients[0]?.ingredientId).to.equal(carrot.id);
expect(translated.ingredients[0]?.quantity).to.equal(3);
});
it("resolves a real multi-word Unit id from the seeded French catalog (issue: matchUnit used to only ever compare a single word)", async () => {
const wheatFlour = await prisma.ingredient.findFirstOrThrow({ where: { key: "wheatFlour" } });
const tablespoon = await prisma.unit.findFirstOrThrow({ where: { key: "tablespoon" } });
const recipe: ParsedRecipe = {
...buildParsedRecipe(["Faire mijoter à feu doux"]),
ingredients: [
{
rawText: "2 cuillères à soupe de farine de blé",
quantity: null,
unit: null,
name: "farine de blé",
},
],
};
const translated = await translateRecipe(recipe, "fr");
expect(translated.ingredients[0]).to.deep.equal({
rawText: "2 cuillères à soupe de farine de blé",
quantity: 2,
unit: null,
name: "farine de blé",
ingredientId: wheatFlour.id,
unitId: tablespoon.id,
});
});
it("still extracts a locale-agnostic quantity even for a locale with no ingredient/unit matching data at all, leaving only the ids null", async () => {
const recipe: ParsedRecipe = { const recipe: ParsedRecipe = {
...buildParsedRecipe(["Faire mijoter à feu doux"]), ...buildParsedRecipe(["Faire mijoter à feu doux"]),
ingredients: [ ingredients: [
@ -461,12 +502,12 @@ describe("recipe-translation", () => {
], ],
}; };
const translated = await translateRecipe(recipe, "fr"); const translated = await translateRecipe(recipe, "de");
expect(translated.ingredients).to.deep.equal([ expect(translated.ingredients).to.deep.equal([
{ {
rawText: "1 cup onions, chopped", rawText: "1 cup onions, chopped",
quantity: null, quantity: 1,
unit: null, unit: null,
name: "onions", name: "onions",
ingredientId: null, ingredientId: null,

View file

@ -0,0 +1,37 @@
import { expect } from "chai";
import { prisma } from "../../src/db/prisma.js";
import {
MIN_OVERALL_F1,
runTechStepEvalSuite,
} from "../../src/lib/recipe-matching/tech-step-eval-runner.js";
import { resetDatabase } from "../../test-support/reset-db.js";
/**
* Regression gate for `TECH_STEP_TRAINING_DATA` every change to that
* corpus (including a maintainer applying suggestions from
* `TechStepTrainingSuggestion`, see `scripts/retrain-tech-steps.ts`) must
* keep this suite green. Runs {@link runTechStepEvalSuite} (the real
* trained classifier against `tech-step-eval-dataset.ts`) and asserts the
* aggregate F1 doesn't fall below {@link MIN_OVERALL_F1} see that
* constant's own doc comment (`tech-step-eval-runner.ts`) for the real run
* it was calibrated against.
*/
describe("tech-step-eval", () => {
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
it(`scores at least ${MIN_OVERALL_F1} aggregate F1 against the labeled evaluation set`, async () => {
const { overall, byKey } = await runTechStepEvalSuite();
expect(
overall.f1,
`aggregate F1 ${overall.f1.toFixed(3)} (precision ${overall.precision.toFixed(3)}, recall ${overall.recall.toFixed(3)}) fell below the ${MIN_OVERALL_F1} floor — per-technique breakdown: ${JSON.stringify(byKey)}`,
).to.be.at.least(MIN_OVERALL_F1);
});
});

View file

@ -118,13 +118,16 @@ describe("tech-step-matcher", () => {
// `techStepClassifier` is the one shared singleton (see // `techStepClassifier` is the one shared singleton (see
// tech-step-matcher.ts's own doc comment on why) — these tests // tech-step-matcher.ts's own doc comment on why) — these tests
// exercise it against the real training corpus // exercise it against the real training corpus
// (`tech-step-training-data.ts`) and the real seeded `TechStep` // (`services/tech-step-intent-service`'s `training_data.py`) and the
// catalog, rather than synthetic injectable fixtures the old // real seeded `TechStep` catalog, rather than synthetic injectable
// regex-based `matchTechStepSpans(description, mappings)` allowed. // fixtures the old regex-based `matchTechStepSpans(description,
// Training + node-nlp's own one-time per-language setup can take a // mappings)` allowed. Every call round-trips over HTTP to a real,
// few seconds on the very first call in the whole suite (subsequent // locally running `services/tech-step-intent-service` (see that
// calls reuse the same trained model and are fast) — comfortably // service's own README and `apps/api/.env.test`) — that service trains
// inside this suite's default 10s timeout (.mocharc.json). // itself once at its own startup (`test-support/mocha-root-hooks.ts`'s
// root hook doesn't wait on it, CI's own "wait for /health" step
// already does), so calls here are just a normal HTTP round-trip,
// comfortably inside this suite's default 10s timeout (.mocharc.json).
let simmerId: number; let simmerId: number;
let cookId: number; let cookId: number;
let bakeId: number; let bakeId: number;
@ -132,10 +135,19 @@ describe("tech-step-matcher", () => {
let meltId: number; let meltId: number;
let boilId: number; let boilId: number;
let chopId: number; let chopId: number;
// Real seeded catalog entries that also happen to be mentioned by
// several fixtures below now that `matchTechStepSpans` also resolves
// ingredient/utensil metadata — see `matchTechStepSpans`'s own describe
// block for where each of these gets used.
let panId: number;
let butterId: number;
let onionId: number;
let walnutsId: number;
beforeEach(async () => { beforeEach(async () => {
await resetDatabase(); await resetDatabase();
const [simmer, cook, bake, preheat, melt, boil, chop] = await Promise.all([ const [simmer, cook, bake, preheat, melt, boil, chop, pan, butter, onion, walnuts] =
await Promise.all([
prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } }), prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "cook" } }), prisma.techStep.findFirstOrThrow({ where: { key: "cook" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "bake" } }), prisma.techStep.findFirstOrThrow({ where: { key: "bake" } }),
@ -143,6 +155,15 @@ describe("tech-step-matcher", () => {
prisma.techStep.findFirstOrThrow({ where: { key: "melt" } }), prisma.techStep.findFirstOrThrow({ where: { key: "melt" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "boil" } }), prisma.techStep.findFirstOrThrow({ where: { key: "boil" } }),
prisma.techStep.findFirstOrThrow({ where: { key: "chop" } }), prisma.techStep.findFirstOrThrow({ where: { key: "chop" } }),
prisma.utensil.findFirstOrThrow({ where: { key: "pan" } }),
prisma.ingredient.findFirstOrThrow({ where: { key: "butter" } }),
prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } }),
// "Noix" (walnuts) — turns out to also be a real seeded ingredient
// label, and "noix" is literally the French word for "a pat of
// butter" ("une noix de beurre") used in one of the fixtures
// below, so it's a genuine (if slightly comical) second match
// alongside "beurre" in that clause, not a fixture bug.
prisma.ingredient.findFirstOrThrow({ where: { key: "walnuts" } }),
]); ]);
simmerId = simmer.id; simmerId = simmer.id;
cookId = cook.id; cookId = cook.id;
@ -151,6 +172,10 @@ describe("tech-step-matcher", () => {
meltId = melt.id; meltId = melt.id;
boilId = boil.id; boilId = boil.id;
chopId = chop.id; chopId = chop.id;
panId = pan.id;
butterId = butter.id;
onionId = onion.id;
walnutsId = walnuts.id;
}); });
after(async () => { after(async () => {
@ -252,7 +277,15 @@ describe("tech-step-matcher", () => {
const text = "Faire mijoter à feu doux"; const text = "Faire mijoter à feu doux";
const result = await techStepClassifier.matchTechStepSpans(text, "fr"); const result = await techStepClassifier.matchTechStepSpans(text, "fr");
expect(result).to.deep.equal([ expect(result).to.deep.equal([
{ techStepId: simmerId, start: 6, end: 13, contextStart: 0, contextEnd: text.length }, {
techStepId: simmerId,
start: 6,
end: 13,
contextStart: 0,
contextEnd: text.length,
ingredients: [],
utensils: [],
},
]); ]);
expect(text.slice(6, 13).toLowerCase()).to.equal("mijoter"); expect(text.slice(6, 13).toLowerCase()).to.equal("mijoter");
}); });
@ -302,6 +335,12 @@ describe("tech-step-matcher", () => {
end: 21, end: 21,
contextStart: 0, contextStart: 0,
contextEnd: 22, contextEnd: 22,
// "poêle" (the pan) sits inside this very clause — a separate
// utensil mention from `preheat`'s own "poêle chaude" keyword
// span above, found by the intent service's *other* PhraseMatcher
// (see `IntentServiceEntity.kind`).
ingredients: [],
utensils: [{ utensilId: panId, start: 9, end: 14 }],
}); });
expect(result[1]).to.deep.equal({ expect(result[1]).to.deep.equal({
techStepId: meltId, techStepId: meltId,
@ -309,6 +348,15 @@ describe("tech-step-matcher", () => {
end: 37, end: 37,
contextStart: 22, contextStart: 22,
contextEnd: text.length, contextEnd: text.length,
// Two mentions in this clause: "noix" (walnuts — also a real
// seeded ingredient, and literally the French word this phrase
// uses for "a pat of [butter]") *and* "beurre" itself, in
// reading order.
ingredients: [
{ ingredientId: walnutsId, start: 42, end: 46, quantity: null, unitId: null },
{ ingredientId: butterId, start: 50, end: 56, quantity: null, unitId: null },
],
utensils: [],
}); });
expect(text.slice(result[0].start, result[0].end)).to.equal("poêle chaude"); expect(text.slice(result[0].start, result[0].end)).to.equal("poêle chaude");
expect(text.slice(result[0].contextStart, result[0].contextEnd)).to.equal( expect(text.slice(result[0].contextStart, result[0].contextEnd)).to.equal(
@ -330,6 +378,15 @@ describe("tech-step-matcher", () => {
end: text.length, end: text.length,
contextStart: 0, contextStart: 0,
contextEnd: text.length, contextEnd: text.length,
// "beurre" and "poêle" are both mentioned in this same
// anchor-less clause (there's no literal `melt` keyword here at
// all — the whole point of this test, see its own title) —
// still resolved, since ingredient/utensil scanning doesn't
// depend on the clause having a technique anchor of its own.
ingredients: [
{ ingredientId: butterId, start: 18, end: 24, quantity: null, unitId: null },
],
utensils: [{ utensilId: panId, start: 45, end: 50 }],
}, },
]); ]);
}); });
@ -338,10 +395,33 @@ describe("tech-step-matcher", () => {
const text = "Chop the onions finely"; const text = "Chop the onions finely";
const result = await techStepClassifier.matchTechStepSpans(text, "en"); const result = await techStepClassifier.matchTechStepSpans(text, "en");
expect(result).to.deep.equal([ expect(result).to.deep.equal([
{ techStepId: chopId, start: 0, end: 4, contextStart: 0, contextEnd: text.length }, {
techStepId: chopId,
start: 0,
end: 4,
contextStart: 0,
contextEnd: text.length,
ingredients: [
{ ingredientId: onionId, start: 9, end: 15, quantity: null, unitId: null },
],
utensils: [],
},
]); ]);
expect(text.slice(0, 4)).to.equal("Chop"); expect(text.slice(0, 4)).to.equal("Chop");
}); });
// Quantity+unit extraction itself (the leading-number-before-a-mention
// heuristic) is covered in full, deterministically, by
// `findIngredientMentions`'s own tests (`ingredient-matcher.test.ts`)
// — deliberately not re-exercised here through a brand-new invented
// sentence: a novel combination of words the real `textcat` (trained
// on a fixed, finite corpus, see `training_data.py`) has never seen
// together can land on a confidently-wrong technique for reasons
// that have nothing to do with this file's own logic, making such a
// test flaky against corpus/threshold changes rather than a
// trustworthy regression guard. The two tests above/below already
// demonstrate technique+ingredient+utensil co-occurring in one
// clause using sentences already proven reliable by this suite.
}); });
}); });
}); });

View file

@ -0,0 +1,341 @@
import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { sevenFiftyGAdapter } from "../../src/sources/750g.js";
/** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as `json-ld-recipe.test.ts`'s `stubFetchHtml`. */
function stubFetchHtml(html: string, status = 200) {
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}
const RECIPE_URL = "https://www.750g.com/poulet-au-vin-jaune-et-aux-morilles-r3844.htm";
/**
* A raw (not `JSON.stringify`-escaped) JSON-LD `Recipe` payload, deliberately
* reproducing two real 750g.com bugs verified live on the recipe this test's
* URL/content is modeled after:
* - a literal, unescaped `\r\n` inside `recipeInstructions[0].text` (invalid
* JSON as-is this is exactly what {@link sanitizeJsonLdBlocks} in the
* adapter under test has to repair before `JSON.parse` can succeed);
* - `Pr&amp;eacute;parez` a real "é" that went through 750g's own
* HTML-entity encoder twice (`decodeHtmlEntities` has to run twice to
* fully resolve it back to "é").
* Plus a plain `&#039;` apostrophe entity in an ingredient line, the more
* common single-encoding case.
*/
const RAW_RECIPE_JSON_LD = `{
"@context": "https://schema.org",
"@type": "Recipe",
"name": "Poulet au vin jaune et aux morilles",
"description": "Une recette de f\\u00eate.",
"image": {"@type": "ImageObject", "url": "https://static.750g.com/images/poulet-vin-jaune.jpg"},
"recipeYield": "6 personnes",
"recipeIngredient": ["1 poulet fermier", "Sel &#039;fin&#039;"],
"recipeInstructions": [
{"@type": "HowToStep", "text": "Pr&amp;eacute;parez les morilles :\r\nFendez-les en deux."}
],
"url": "${RECIPE_URL}"
}`;
function htmlWithRawJsonLd(rawJson: string): string {
return `<!doctype html><html><head><script type="application/ld+json">${rawJson}</script></head><body></body></html>`;
}
describe("sevenFiftyGAdapter", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("declares itself as an unofficial, French-locale source with an icon", () => {
expect(sevenFiftyGAdapter.key).to.equal("750g");
expect(sevenFiftyGAdapter.name).to.equal("750g");
expect(sevenFiftyGAdapter.official).to.equal(false);
expect(sevenFiftyGAdapter.iconUrl).to.be.a("string");
expect(sevenFiftyGAdapter.locale).to.equal("fr");
});
describe("list", () => {
/**
* Models the real shape found live: a card's own `<img>` sits
* immediately before its `<a class="card-link">`, but the fragment also
* carries decorative images that belong to no card at all (verified
* live: 28 `<img>` tags against 23 real cards for one sample query)
* an image search that isn't "nearest preceding, not naive same-index
* zip" would misattribute every card after the first stray image.
*/
const CARDS_HTML = `
<div class="grid">
<img src="https://static.750g.com/images/x/orphan-lead.jpg" class="decorative" />
<div class="card">
<img src="https://static.750g.com/images/x/tarte.jpg" alt="Tarte" />
<a href="https://www.750g.com/tarte-aux-pommes-r1.htm" class="card-link ">Tarte aux pommes</a>
</div>
<img src="https://static.750g.com/images/x/orphan-mid-1.jpg" class="decorative" />
<img src="https://static.750g.com/images/x/orphan-mid-2.jpg" class="decorative" />
<div class="card">
<img src="https://static.750g.com/images/x/gratin.jpg" alt="Gratin" />
<a href="https://www.750g.com/gratin-dauphinois-r2.htm" class="card-link ">Gratin dauphinois</a>
</div>
<div class="card">
<a href="https://www.750g.com/pain-perdu-r3.htm" class="card-link ">Pain perdu</a>
</div>
</div>
`;
it("scrapes each card's title/url/image, matching each image to its nearest preceding link and ignoring orphan images", async () => {
stubFetchHtml(CARDS_HTML);
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
expect(result.items).to.deep.equal([
{
externalId: "https://www.750g.com/tarte-aux-pommes-r1.htm",
title: "Tarte aux pommes",
picture: "https://static.750g.com/images/x/tarte.jpg",
url: "https://www.750g.com/tarte-aux-pommes-r1.htm",
},
{
externalId: "https://www.750g.com/gratin-dauphinois-r2.htm",
title: "Gratin dauphinois",
picture: "https://static.750g.com/images/x/gratin.jpg",
url: "https://www.750g.com/gratin-dauphinois-r2.htm",
},
{
externalId: "https://www.750g.com/pain-perdu-r3.htm",
title: "Pain perdu",
picture: null,
url: "https://www.750g.com/pain-perdu-r3.htm",
},
]);
});
it("decodes HTML entities in a card's title", async () => {
stubFetchHtml(
`<a href="https://www.750g.com/tarte-r1.htm" class="card-link ">Tarte aux pommes &#039;reinettes&#039;</a>`,
);
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
expect(result.items[0]?.title).to.equal("Tarte aux pommes 'reinettes'");
});
it("always returns nextCursor: null — this search isn't really paginated (requesting a further page comes back empty)", async () => {
stubFetchHtml(CARDS_HTML);
const result = await sevenFiftyGAdapter.list({ query: "tarte" });
expect(result.nextCursor).to.be.null;
});
it("ignores params.cursor for a text search — always requests page=1, there's never a legitimate cursor for this (non-paginated) endpoint", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response("", { status: 200 });
}) as typeof fetch;
await sevenFiftyGAdapter.list({ query: "tarte", cursor: "7" });
expect(requestedUrl).to.include("page=1");
expect(requestedUrl).not.to.include("page=7");
});
it("URL-encodes the query", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response("", { status: 200 });
}) as typeof fetch;
await sevenFiftyGAdapter.list({ query: "tarte aux pommes" });
expect(requestedUrl).to.include("query=tarte%20aux%20pommes");
});
describe("empty/omitted query (browsing with no filter)", () => {
it("reads 'dernières recettes' instead of the AI search — the search endpoint answers a blank query with nothing at all, which would otherwise make browsing with no filter always come back empty", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(CARDS_HTML, { status: 200 });
}) as typeof fetch;
const result = await sevenFiftyGAdapter.list({});
expect(requestedUrl).to.include("dernieres-recettes.htm");
expect(requestedUrl).not.to.include("genius/query");
expect(result.items).to.have.length(3);
});
it("also browses for an explicitly empty query string, not just an omitted one", async () => {
stubFetchHtml(CARDS_HTML);
const result = await sevenFiftyGAdapter.list({ query: "" });
expect(result.items).to.have.length(3);
});
it("requests the given cursor's page", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(CARDS_HTML, { status: 200 });
}) as typeof fetch;
await sevenFiftyGAdapter.list({ cursor: "5" });
expect(requestedUrl).to.include("page=5");
});
it("offers a next page when the page has cards, and none once a page comes back empty — this endpoint never 404s/redirects past its real end", async () => {
stubFetchHtml(CARDS_HTML);
const withItems = await sevenFiftyGAdapter.list({ cursor: "2" });
expect(withItems.nextCursor).to.equal("3");
stubFetchHtml("<html><body>Plus rien ici</body></html>");
const empty = await sevenFiftyGAdapter.list({ cursor: "50" });
expect(empty.nextCursor).to.be.null;
expect(empty.items).to.deep.equal([]);
});
});
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
stubFetchHtml("", 500);
try {
await sevenFiftyGAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g");
}
});
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
globalThis.fetch = (async () => {
throw new Error("network down");
}) as typeof fetch;
try {
await sevenFiftyGAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
}
});
});
describe("fetchDetail", () => {
it("fetches the given recipe URL and returns its html alongside the url", async () => {
stubFetchHtml(htmlWithRawJsonLd(RAW_RECIPE_JSON_LD));
const result = await sevenFiftyGAdapter.fetchDetail(RECIPE_URL);
expect(result.url).to.equal(RECIPE_URL);
expect(result.html).to.include("Poulet au vin jaune");
});
it("throws a RecipeSourceFetchError keyed to 750g, not the underlying generic adapter", async () => {
stubFetchHtml("", 404);
try {
await sevenFiftyGAdapter.fetchDetail(RECIPE_URL);
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("750g");
}
});
});
describe("parse", () => {
it("repairs a raw unescaped \\r\\n inside a JSON-LD string that would otherwise fail JSON.parse", () => {
const parsed = sevenFiftyGAdapter.parse({
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
url: RECIPE_URL,
});
expect(parsed.name).to.equal("Poulet au vin jaune et aux morilles");
expect(parsed.steps).to.deep.equal([
{ description: "Préparez les morilles :\r\nFendez-les en deux.", picture: null },
]);
});
it("decodes a double HTML-entity-encoded accented character (é -> &eacute; -> &amp;eacute;)", () => {
const parsed = sevenFiftyGAdapter.parse({
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
url: RECIPE_URL,
});
expect(parsed.steps[0]?.description).to.include("Préparez");
});
it("decodes a plain numeric apostrophe entity in ingredient text", () => {
const parsed = sevenFiftyGAdapter.parse({
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
url: RECIPE_URL,
});
expect(parsed.ingredients).to.deep.equal([
{ rawText: "1 poulet fermier", quantity: null, unit: null, name: "1 poulet fermier" },
{ rawText: "Sel 'fin'", quantity: null, unit: null, name: "Sel 'fin'" },
]);
});
it("leaves picture/sourceUrl untouched by entity decoding", () => {
const parsed = sevenFiftyGAdapter.parse({
html: htmlWithRawJsonLd(RAW_RECIPE_JSON_LD),
url: RECIPE_URL,
});
expect(parsed.picture).to.equal("https://static.750g.com/images/poulet-vin-jaune.jpg");
expect(parsed.sourceUrl).to.equal(RECIPE_URL);
});
it("maps a recipe with no quirks end to end, same as the generic adapter would", () => {
const clean = {
"@context": "https://schema.org",
"@type": "Recipe",
name: "Tarte aux pommes",
description: "Une tarte classique.",
image: "https://static.750g.com/images/tarte.jpg",
recipeYield: 6,
recipeIngredient: ["3 pommes", "1 pâte brisée"],
recipeInstructions: ["Éplucher les pommes.", "Enfourner 30 minutes."],
};
const html = `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
clean,
)}</script></head><body></body></html>`;
const parsed = sevenFiftyGAdapter.parse({ html, url: RECIPE_URL });
expect(parsed.name).to.equal("Tarte aux pommes");
expect(parsed.portions).to.equal(6);
expect(parsed.steps).to.deep.equal([
{ description: "Éplucher les pommes.", picture: null },
{ description: "Enfourner 30 minutes.", picture: null },
]);
});
it("throws a RecipeSourceParseError keyed to 750g, not the underlying generic adapter", () => {
const html = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
try {
sevenFiftyGAdapter.parse({ html, url: RECIPE_URL });
expect.fail("expected parse to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceParseError);
expect((err as RecipeSourceParseError).sourceKey).to.equal("750g");
}
});
});
});

View file

@ -0,0 +1,301 @@
import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { mangerBougerAdapter } from "../../src/sources/manger-bouger.js";
/** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as every other adapter test in this family. */
function stubFetchHtml(html: string, status = 200) {
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}
const DETAIL_URL =
"https://www.mangerbouger.fr/manger-mieux/la-fabrique-a-menus/recettes/2854-salade-de-pates-aux-courgettes";
/** Wraps a `props.initialState.recipes` payload (the shape `list()` reads) in a minimal `__NEXT_DATA__` script tag, the same server-rendered hydration data every mangerbouger.fr Next.js page carries. */
function htmlWithListNextData(recipesState: unknown): string {
const payload = { props: { initialState: { recipes: recipesState } } };
return `<!doctype html><html><head></head><body><script id="__NEXT_DATA__" type="application/json">${JSON.stringify(
payload,
)}</script></body></html>`;
}
/**
* A real Slate.js rich-text document (paragraph + bulleted-list of
* list-items, the only block types ever observed live), JSON-stringified
* exactly the shape mangerbouger.fr's own JSON-LD embeds as a `HowToStep`'s
* `text` field.
*/
const SLATE_STEP_DOCUMENT = JSON.stringify([
{ type: "paragraph", children: [{ text: "Cuisson des courgettes", bold: true }] },
{
type: "bulleted-list",
children: [
{ type: "list-item", children: [{ text: "Épluchez les courgettes" }] },
{ type: "list-item", children: [{ text: "Coupez-les en rondelles" }] },
],
},
]);
/** A JSON-LD `Recipe` payload shaped exactly like a real mangerbouger.fr detail page's — no `recipeYield` (verified absent live on every sampled recipe), `recipeInstructions` holding {@link SLATE_STEP_DOCUMENT} instead of prose. */
const RECIPE_JSON_LD_NO_YIELD = {
"@context": "https://schema.org",
"@type": "Recipe",
name: "Salade de pâtes aux courgettes",
image: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg",
recipeIngredient: ["3 Courgette", "4 cuillères à soupe Huile d'olive"],
recipeInstructions: [{ "@type": "HowToStep", name: "Étape 1", text: SLATE_STEP_DOCUMENT }],
url: DETAIL_URL,
};
/** Wraps a JSON-LD `Recipe` payload (already an object, not yet stringified) and, optionally, a `__NEXT_DATA__` detail-page payload carrying `portions`, in one minimal HTML page — the two independent script tags `parse()` reads. */
function htmlWithDetail(recipeJsonLd: unknown, portions?: number): string {
const nextData = portions === undefined ? "" : htmlWithDetailNextDataScript(portions);
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
recipeJsonLd,
)}</script></head><body>${nextData}</body></html>`;
}
function htmlWithDetailNextDataScript(portions: number): string {
const payload = { props: { initialState: { recipe: { recipe: { portions } } } } };
return `<script id="__NEXT_DATA__" type="application/json">${JSON.stringify(payload)}</script>`;
}
describe("mangerBougerAdapter", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("declares itself as an unofficial, French-locale source with an icon", () => {
expect(mangerBougerAdapter.key).to.equal("mangerBouger");
expect(mangerBougerAdapter.name).to.equal("Manger Bouger");
expect(mangerBougerAdapter.official).to.equal(false);
expect(mangerBougerAdapter.iconUrl).to.be.a("string");
expect(mangerBougerAdapter.locale).to.equal("fr");
});
describe("list", () => {
it("maps __NEXT_DATA__'s recipes.list into RecipeSourceListItems", async () => {
stubFetchHtml(
htmlWithListNextData({
list: [
{
id: "2854",
slug: "2854-salade-de-pates-aux-courgettes",
name: "Salade de pâtes aux courgettes",
image: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg",
},
],
hasMorePages: false,
}),
);
const result = await mangerBougerAdapter.list({ query: "salade" });
expect(result.items).to.deep.equal([
{
externalId: DETAIL_URL,
title: "Salade de pâtes aux courgettes",
picture: "https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg",
url: DETAIL_URL,
},
]);
});
it("offers a next page when hasMorePages is true, and none when false", async () => {
stubFetchHtml(htmlWithListNextData({ list: [], hasMorePages: true }));
expect((await mangerBougerAdapter.list({ query: "x" })).nextCursor).to.equal("2");
stubFetchHtml(htmlWithListNextData({ list: [], hasMorePages: false }));
expect((await mangerBougerAdapter.list({ query: "x" })).nextCursor).to.be.null;
});
it("requests the given cursor's page and URL-encodes the query", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(htmlWithListNextData({ list: [], hasMorePages: false }), {
status: 200,
});
}) as typeof fetch;
await mangerBougerAdapter.list({ query: "crème brûlée", cursor: "3" });
expect(requestedUrl).to.include("page=3");
expect(requestedUrl).to.include("query=cr%C3%A8me%20br%C3%BBl%C3%A9e");
});
it("skips a list entry missing a slug or a name", async () => {
stubFetchHtml(
htmlWithListNextData({
list: [
{ id: "1", name: "No slug", image: null },
{ id: "2", slug: "no-name", image: null },
],
hasMorePages: false,
}),
);
const result = await mangerBougerAdapter.list({ query: "x" });
expect(result.items).to.deep.equal([]);
});
it("returns an empty page rather than throwing when the page has no __NEXT_DATA__ at all", async () => {
stubFetchHtml("<!doctype html><html><body>Rien ici</body></html>");
const result = await mangerBougerAdapter.list({ query: "x" });
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
stubFetchHtml("", 500);
try {
await mangerBougerAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("mangerBouger");
}
});
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
globalThis.fetch = (async () => {
throw new Error("network down");
}) as typeof fetch;
try {
await mangerBougerAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
}
});
});
describe("fetchDetail", () => {
it("fetches the given recipe URL and returns its html alongside the url", async () => {
stubFetchHtml(htmlWithDetail(RECIPE_JSON_LD_NO_YIELD));
const result = await mangerBougerAdapter.fetchDetail(DETAIL_URL);
expect(result.url).to.equal(DETAIL_URL);
expect(result.html).to.include("Salade de p");
});
it("throws a RecipeSourceFetchError keyed to mangerBouger, not the underlying generic adapter", async () => {
stubFetchHtml("", 404);
try {
await mangerBougerAdapter.fetchDetail(DETAIL_URL);
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("mangerBouger");
}
});
});
describe("parse", () => {
it("flattens a Slate.js rich-text step into readable plain text", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD),
url: DETAIL_URL,
});
expect(parsed.steps).to.deep.equal([
{
description:
"Cuisson des courgettes\n- Épluchez les courgettes\n- Coupez-les en rondelles",
picture: null,
},
]);
});
it("backfills recipeYield/portions from __NEXT_DATA__ when the JSON-LD itself doesn't state one", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD, 4),
url: DETAIL_URL,
});
expect(parsed.portions).to.equal(4);
});
it("leaves portions null when __NEXT_DATA__ has no portions to backfill from either", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD),
url: DETAIL_URL,
});
expect(parsed.portions).to.be.null;
});
it("doesn't override recipeYield when the JSON-LD already states one", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail({ ...RECIPE_JSON_LD_NO_YIELD, recipeYield: 8 }, 4),
url: DETAIL_URL,
});
expect(parsed.portions).to.equal(8);
});
it("leaves an already-plain-text step untouched rather than mangling it", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail({
...RECIPE_JSON_LD_NO_YIELD,
recipeInstructions: [{ "@type": "HowToStep", text: "Faites bouillir de l'eau." }],
}),
url: DETAIL_URL,
});
expect(parsed.steps).to.deep.equal([
{ description: "Faites bouillir de l'eau.", picture: null },
]);
});
it("maps name/image/ingredients end to end via the underlying generic adapter", () => {
const parsed = mangerBougerAdapter.parse({
html: htmlWithDetail(RECIPE_JSON_LD_NO_YIELD),
url: DETAIL_URL,
});
expect(parsed.name).to.equal("Salade de pâtes aux courgettes");
expect(parsed.picture).to.equal(
"https://api-prod-fam.mangerbouger.fr/storage/recettes/salade.jpg",
);
expect(parsed.sourceUrl).to.equal(DETAIL_URL);
expect(parsed.ingredients).to.deep.equal([
{ rawText: "3 Courgette", quantity: null, unit: null, name: "3 Courgette" },
{
rawText: "4 cuillères à soupe Huile d'olive",
quantity: null,
unit: null,
name: "4 cuillères à soupe Huile d'olive",
},
]);
});
it("throws a RecipeSourceParseError keyed to mangerBouger, not the underlying generic adapter", () => {
const html = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
try {
mangerBougerAdapter.parse({ html, url: DETAIL_URL });
expect.fail("expected parse to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceParseError);
expect((err as RecipeSourceParseError).sourceKey).to.equal("mangerBouger");
}
});
});
});

View file

@ -0,0 +1,226 @@
import { expect } from "chai";
import {
RecipeSourceFetchError,
RecipeSourceParseError,
} from "../../src/lib/recipe-sources/recipe-source-errors.js";
import { marmitonAdapter } from "../../src/sources/marmiton.js";
/** Stubs `globalThis.fetch` to return `html` as the response body — same pattern as `json-ld-recipe.test.ts`'s `stubFetchHtml`. */
function stubFetchHtml(html: string, status = 200) {
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
}
/**
* Wraps a schema.org `ItemList` payload (already an object, not yet
* stringified) in a minimal HTML page carrying it as one
* `<script type="application/ld+json">` block the shape marmiton.org's
* search-results page embeds `list()` reads.
*/
function htmlWithItemListJsonLd(itemListElement: unknown[]): string {
const payload = {
"@context": "https://schema.org",
"@graph": [
{ "@type": "WebSite", name: "Marmiton" },
{
"@type": "ItemList",
"@id": "https://www.marmiton.org/recettes/recherche.aspx?aqt=poulet#itemlist",
numberOfItems: itemListElement.length,
itemListElement,
},
],
};
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
payload,
)}</script></head><body></body></html>`;
}
const RECIPE_URL = "https://www.marmiton.org/recettes/recette_tarte-aux-pommes_11457.aspx";
const baseListItem = {
"@type": "ListItem",
position: 1,
url: RECIPE_URL,
name: "Tarte aux pommes",
image: "https://assets.afcdn.com/recipe/tarte.jpg",
};
const baseRecipeJsonLd = {
"@context": "https://schema.org",
"@type": "Recipe",
name: "Tarte aux pommes",
description: "Une tarte aux pommes classique.",
image: "https://assets.afcdn.com/recipe/tarte.jpg",
recipeYield: "6 personnes",
recipeIngredient: ["3 pommes", "1 pâte brisée"],
recipeInstructions: [
{ "@type": "HowToStep", text: "Épluchez les pommes." },
{ "@type": "HowToStep", text: "Enfournez 30 minutes." },
],
};
function htmlWithRecipeJsonLd(): string {
return `<!doctype html><html><head><script type="application/ld+json">${JSON.stringify(
baseRecipeJsonLd,
)}</script></head><body></body></html>`;
}
describe("marmitonAdapter", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("declares itself as an unofficial, French-locale source with an icon", () => {
expect(marmitonAdapter.key).to.equal("marmiton");
expect(marmitonAdapter.name).to.equal("Marmiton");
expect(marmitonAdapter.official).to.equal(false);
expect(marmitonAdapter.iconUrl).to.be.a("string");
expect(marmitonAdapter.locale).to.equal("fr");
});
describe("list", () => {
it("maps the search page's ItemList into RecipeSourceListItems and offers a next page", async () => {
stubFetchHtml(htmlWithItemListJsonLd([baseListItem]));
const result = await marmitonAdapter.list({ query: "tarte aux pommes" });
expect(result.items).to.deep.equal([
{
externalId: RECIPE_URL,
title: "Tarte aux pommes",
picture: "https://assets.afcdn.com/recipe/tarte.jpg",
url: RECIPE_URL,
},
]);
expect(result.nextCursor).to.equal("2");
});
it("requests the given cursor's page and stops offering a next page once a page comes back empty", async () => {
let requestedUrl: string | undefined;
globalThis.fetch = (async (url: string) => {
requestedUrl = url;
return new Response(htmlWithItemListJsonLd([]), { status: 200 });
}) as typeof fetch;
const result = await marmitonAdapter.list({ query: "tarte", cursor: "3" });
expect(requestedUrl).to.include("page=3");
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("treats a 404 (page past the last one) as an empty final page, not a failure", async () => {
stubFetchHtml("", 404);
const result = await marmitonAdapter.list({ query: "tarte", cursor: "999" });
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("skips a ListItem missing a url or a name", async () => {
stubFetchHtml(
htmlWithItemListJsonLd([
{ "@type": "ListItem", position: 1, name: "No url" },
{ "@type": "ListItem", position: 2, url: RECIPE_URL },
]),
);
const result = await marmitonAdapter.list({ query: "x" });
expect(result.items).to.deep.equal([]);
});
it("returns an empty page rather than throwing when the page has no ItemList at all", async () => {
stubFetchHtml("<!doctype html><html><body>Rien ici</body></html>");
const result = await marmitonAdapter.list({ query: "x" });
expect(result.items).to.deep.equal([]);
expect(result.nextCursor).to.be.null;
});
it("throws RecipeSourceFetchError on a non-2xx, non-404 response", async () => {
stubFetchHtml("", 500);
try {
await marmitonAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
}
});
it("throws RecipeSourceFetchError when the network request itself fails", async () => {
globalThis.fetch = (async () => {
throw new Error("network down");
}) as typeof fetch;
try {
await marmitonAdapter.list({ query: "x" });
expect.fail("expected list to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
}
});
});
describe("fetchDetail", () => {
it("fetches the given recipe URL and returns its html alongside the url", async () => {
stubFetchHtml(htmlWithRecipeJsonLd());
const result = await marmitonAdapter.fetchDetail(RECIPE_URL);
expect(result.url).to.equal(RECIPE_URL);
expect(result.html).to.include("Tarte aux pommes");
});
it("throws a RecipeSourceFetchError keyed to marmiton, not the underlying generic adapter", async () => {
stubFetchHtml("", 404);
try {
await marmitonAdapter.fetchDetail(RECIPE_URL);
expect.fail("expected fetchDetail to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceFetchError);
expect((err as RecipeSourceFetchError).sourceKey).to.equal("marmiton");
}
});
});
describe("parse", () => {
it("delegates to the generic JSON-LD parser end to end", () => {
const parsed = marmitonAdapter.parse({ html: htmlWithRecipeJsonLd(), url: RECIPE_URL });
expect(parsed.name).to.equal("Tarte aux pommes");
expect(parsed.portions).to.equal(6);
expect(parsed.sourceUrl).to.equal(RECIPE_URL);
expect(parsed.ingredients).to.deep.equal([
{ rawText: "3 pommes", quantity: null, unit: null, name: "3 pommes" },
{ rawText: "1 pâte brisée", quantity: null, unit: null, name: "1 pâte brisée" },
]);
expect(parsed.steps).to.deep.equal([
{ description: "Épluchez les pommes.", picture: null },
{ description: "Enfournez 30 minutes.", picture: null },
]);
});
it("throws a RecipeSourceParseError keyed to marmiton, not the underlying generic adapter", () => {
const html = "<!doctype html><html><body>Pas de JSON-LD ici</body></html>";
try {
marmitonAdapter.parse({ html, url: RECIPE_URL });
expect.fail("expected parse to throw");
} catch (err) {
expect(err).to.be.instanceOf(RecipeSourceParseError);
expect((err as RecipeSourceParseError).sourceKey).to.equal("marmiton");
}
});
});
});

View file

@ -0,0 +1,550 @@
import type { SignupInput } from "@batch-cooking/shared";
import { ErrorCode } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../../src/app.js";
import { prisma } from "../../src/db/prisma.js";
import { resetDatabase } from "../../test-support/reset-db.js";
/** See `auth.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** Resolves a reference tech step's id by its `reference-seed-data.ts` uid (also its DB `key`) — mirrors `recipe.test.ts`'s own `techStepId` helper. */
async function techStepId(key: string): Promise<number> {
const techStep = await prisma.techStep.findFirstOrThrow({ where: { key } });
return techStep.id;
}
/** Same as {@link techStepId}, for a reference `Ingredient`. */
async function ingredientId(key: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
return ingredient.id;
}
/** Same as {@link techStepId}, for a reference `Unit`. */
async function unitId(key: string): Promise<number> {
const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
return unit.id;
}
/** Same as {@link techStepId}, for a reference `Utensil`. */
async function utensilId(key: string): Promise<number> {
const utensil = await prisma.utensil.findFirstOrThrow({ where: { key } });
return utensil.id;
}
describe("Recipe tech-step corrections", () => {
const app = createApp();
async function signup(): Promise<{ agent: ReturnType<typeof request.agent>; profileId: number }> {
const agent = request.agent(app);
const res = await agent.post("/auth/signup").send(buildSignupPayload());
return { agent, profileId: res.body.id };
}
/** A `PUBLIC` recipe with one step — every viewer can see this, so most tests below don't need to juggle visibility on top of the correction logic itself. */
async function createPublicRecipeWithStep(
authorId: number,
description = "Faire mijoter la sauce.",
): Promise<{ recipeId: number; stepId: number }> {
const recipe = await prisma.recipe.create({
data: {
name: "Recette",
authorId,
visibility: "PUBLIC",
portions: 4,
steps: { create: [{ description, order: 0 }] },
},
include: { steps: true },
});
const step = recipe.steps[0];
if (!step) throw new Error("expected the fixture recipe to have one step");
return { recipeId: recipe.id, stepId: step.id };
}
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("POST /recipes/:id/steps/:stepId/corrections", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const { profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await request(app)
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("records a correction adding a missing technique (no previousTechStepId), and applies it immediately to the step's own techSteps", async () => {
const { agent, profileId } = await signup();
// "Faire mijoter la sauce." names no technique the classifier itself
// registers a bare-word anchor for at this exact span in isolation
// (see services/tech-step-intent-service's training_data.py) — irrelevant here either way,
// since this test's whole point is the *manual* addition, not
// whatever the classifier does or doesn't auto-detect for it.
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
expect(res.status).to.equal(201);
expect(res.body.correction.previousTechStep).to.equal(null);
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
expect(res.body.correction.start).to.equal(6);
expect(res.body.correction.end).to.equal(13);
// The step's real technique sequence reflects the correction right
// away — not just the permanent audit record above (see
// `applyManualCorrection`, `recipe-tech-step-correction.service.ts`).
expect(res.body.techSteps).to.deep.equal([
{
techStep: { id: simmerId, key: "simmer" },
start: 6,
end: 13,
source: "manual",
ingredients: [],
utensils: [],
},
]);
});
it("records a correction relabeling an existing match (both ids set), updating the existing techSteps entry in place", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const boilId = await techStepId("boil");
// First correction creates the "manual" entry this test then relabels
// — exercises the UPDATE branch of `applyManualCorrection`, not the
// INSERT one the previous test already covers.
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
expect(res.status).to.equal(201);
expect(res.body.correction.previousTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
expect(res.body.correction.correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
// Still exactly one entry — the relabel updated the existing row
// rather than adding a second one alongside it.
expect(res.body.techSteps).to.deep.equal([
{
techStep: { id: boilId, key: "boil" },
start: 6,
end: 13,
source: "manual",
ingredients: [],
utensils: [],
},
]);
});
it("deletes the matching techSteps entry when correctedTechStepId is null (a removal)", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: null });
expect(res.status).to.equal(201);
expect(res.body.correction.correctedTechStep).to.equal(null);
expect(res.body.techSteps).to.deep.equal([]);
});
it("is not restricted to the recipe's author — any viewer who can see it may correct it", async () => {
const { profileId: authorId } = await signup();
const { agent: otherAgent } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(authorId);
const res = await otherAgent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: await techStepId("simmer") });
expect(res.status).to.equal(201);
});
it("rejects both previousTechStepId and correctedTechStepId absent with 400 VALIDATION_ERROR", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 0, end: 5 });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects end <= start with 400 VALIDATION_ERROR", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 5, end: 5, correctedTechStepId: await techStepId("simmer") });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects a span past the end of the step's description with 400 INVALID_CORRECTION_SPAN", async () => {
const { agent, profileId } = await signup();
const description = "Court.";
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 0,
end: description.length + 10,
correctedTechStepId: await techStepId("simmer"),
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.INVALID_CORRECTION_SPAN);
});
it("rejects an unknown correctedTechStepId with 404 TECH_STEP_NOT_FOUND", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 0, end: 5, correctedTechStepId: 999_999 });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.TECH_STEP_NOT_FOUND);
});
it("rejects a step that exists but isn't visible to the viewer with 404 RECIPE_NOT_FOUND", async () => {
const { profileId: authorId } = await signup();
const { agent: otherAgent } = await signup();
const recipe = await prisma.recipe.create({
data: {
name: "Secrète",
authorId,
portions: 4,
steps: { create: [{ description: "Faire mijoter la sauce.", order: 0 }] },
},
include: { steps: true },
});
const step = recipe.steps[0];
if (!step) throw new Error("expected the fixture recipe to have one step");
const res = await otherAgent
.post(`/recipes/${recipe.id}/steps/${step.id}/corrections`)
.send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
});
it("rejects a stepId that belongs to a different recipe than the URL's :id with 404 STEP_NOT_FOUND", async () => {
const { agent, profileId } = await signup();
const { recipeId: otherRecipeId } = await createPublicRecipeWithStep(profileId);
const { stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent
.post(`/recipes/${otherRecipeId}/steps/${stepId}/corrections`)
.send({ start: 0, end: 5, correctedTechStepId: await techStepId("simmer") });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.STEP_NOT_FOUND);
});
});
describe("POST /recipes/:id/steps/:stepId/corrections — ingredients/utensils metadata", () => {
it("attaches manually-selected ingredients and utensils to a corrected technique", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const butterId = await ingredientId("butter");
const gramId = await unitId("gram");
const panId = await utensilId("pan");
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: simmerId,
ingredients: [{ ingredientId: butterId, quantity: 50, unitId: gramId, start: 0, end: 6 }],
utensils: [{ utensilId: panId, start: 14, end: 23 }],
});
expect(res.status).to.equal(201);
expect(res.body.techSteps).to.deep.equal([
{
techStep: { id: simmerId, key: "simmer" },
start: 6,
end: 13,
source: "manual",
ingredients: [
{
ingredient: res.body.techSteps[0].ingredients[0].ingredient,
quantity: 50,
unit: res.body.techSteps[0].ingredients[0].unit,
start: 0,
end: 6,
source: "manual",
},
],
utensils: [
{
utensil: res.body.techSteps[0].utensils[0].utensil,
start: 14,
end: 23,
source: "manual",
},
],
},
]);
expect(res.body.techSteps[0].ingredients[0].ingredient.id).to.equal(butterId);
expect(res.body.techSteps[0].ingredients[0].unit.id).to.equal(gramId);
expect(res.body.techSteps[0].utensils[0].utensil).to.deep.equal({ id: panId, key: "pan" });
});
it("attaches an ingredient with no quantity/unit (both omitted)", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const butterId = await ingredientId("butter");
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: simmerId,
ingredients: [{ ingredientId: butterId, start: 0, end: 6 }],
});
expect(res.status).to.equal(201);
expect(res.body.techSteps[0].ingredients[0].quantity).to.equal(null);
expect(res.body.techSteps[0].ingredients[0].unit).to.equal(null);
});
it("replaces both auto-detected and previously-manual metadata on the same occurrence — never accumulates", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const boilId = await techStepId("boil");
const butterId = await ingredientId("butter");
const carrotId = await ingredientId("carrot");
const panId = await utensilId("pan");
const saucepanId = await utensilId("saucepan");
// First correction creates the occurrence (order 0) — simulate an
// auto-detected ingredient already sitting on it, exactly as
// tech-step-matcher.ts would have written one at save time (bypassed
// here for a deterministic fixture, not dependent on the real
// classifier's own output for this text).
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
await prisma.stepTechStepIngredient.create({
data: {
stepId,
techStepOrder: 0,
ingredientId: butterId,
start: 0,
end: 6,
source: "auto",
},
});
await prisma.stepTechStepUtensil.create({
data: { stepId, techStepOrder: 0, utensilId: panId, start: 14, end: 23, source: "auto" },
});
// Second correction — relabels the technique *and* submits a whole
// new, disjoint metadata set.
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
previousTechStepId: simmerId,
correctedTechStepId: boilId,
ingredients: [{ ingredientId: carrotId, start: 0, end: 6 }],
utensils: [{ utensilId: saucepanId, start: 14, end: 23 }],
});
expect(res.status).to.equal(201);
expect(res.body.techSteps).to.have.length(1);
// Neither the auto-detected butter/pan nor an empty leftover row
// survive — only the freshly-submitted carrot/saucepan.
expect(
res.body.techSteps[0].ingredients.map(
(i: { ingredient: { id: number } }) => i.ingredient.id,
),
).to.deep.equal([carrotId]);
expect(
res.body.techSteps[0].utensils.map((u: { utensil: { id: number } }) => u.utensil.id),
).to.deep.equal([saucepanId]);
});
it("leaves existing metadata untouched when ingredients/utensils are omitted from the request", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const boilId = await techStepId("boil");
const butterId = await ingredientId("butter");
await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: simmerId,
ingredients: [{ ingredientId: butterId, start: 0, end: 6 }],
});
// Relabels the technique again, but says nothing about metadata at all.
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
previousTechStepId: simmerId,
correctedTechStepId: boilId,
});
expect(res.status).to.equal(201);
expect(res.body.techSteps[0].ingredients).to.have.length(1);
expect(res.body.techSteps[0].ingredients[0].ingredient.id).to.equal(butterId);
});
it("rejects metadata submitted alongside correctedTechStepId: null with 400 VALIDATION_ERROR", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const butterId = await ingredientId("butter");
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
previousTechStepId: simmerId,
correctedTechStepId: null,
ingredients: [{ ingredientId: butterId, start: 0, end: 6 }],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects an unknown ingredientId with 404 INGREDIENT_NOT_FOUND", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: await techStepId("simmer"),
ingredients: [{ ingredientId: 999_999, start: 0, end: 6 }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.INGREDIENT_NOT_FOUND);
});
it("rejects an unknown unitId with 404 UNIT_NOT_FOUND", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: await techStepId("simmer"),
ingredients: [
{ ingredientId: await ingredientId("butter"), unitId: 999_999, start: 0, end: 6 },
],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.UNIT_NOT_FOUND);
});
it("rejects an unknown utensilId with 404 UTENSIL_NOT_FOUND", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 6,
end: 13,
correctedTechStepId: await techStepId("simmer"),
utensils: [{ utensilId: 999_999, start: 0, end: 6 }],
});
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.UTENSIL_NOT_FOUND);
});
it("rejects a metadata span past the end of the step's description with 400 INVALID_CORRECTION_SPAN", async () => {
const { agent, profileId } = await signup();
const description = "Court.";
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId, description);
const res = await agent.post(`/recipes/${recipeId}/steps/${stepId}/corrections`).send({
start: 0,
end: description.length,
correctedTechStepId: await techStepId("simmer"),
ingredients: [
{ ingredientId: await ingredientId("butter"), start: 0, end: description.length + 10 },
],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.INVALID_CORRECTION_SPAN);
});
});
describe("GET /recipes/:id/steps/:stepId/corrections", () => {
it("returns every correction submitted for the step, most recent first", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const simmerId = await techStepId("simmer");
const boilId = await techStepId("boil");
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, correctedTechStepId: simmerId });
await agent
.post(`/recipes/${recipeId}/steps/${stepId}/corrections`)
.send({ start: 6, end: 13, previousTechStepId: simmerId, correctedTechStepId: boilId });
const res = await agent.get(`/recipes/${recipeId}/steps/${stepId}/corrections`);
expect(res.status).to.equal(200);
expect(res.body).to.have.length(2);
expect(res.body[0].correctedTechStep).to.deep.equal({ id: boilId, key: "boil" });
expect(res.body[1].correctedTechStep).to.deep.equal({ id: simmerId, key: "simmer" });
});
it("returns an empty list when nothing has been submitted yet", async () => {
const { agent, profileId } = await signup();
const { recipeId, stepId } = await createPublicRecipeWithStep(profileId);
const res = await agent.get(`/recipes/${recipeId}/steps/${stepId}/corrections`);
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
});
});

View file

@ -3,7 +3,7 @@ import request from "supertest";
import { createApp } from "../src/app.js"; import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js"; import { prisma } from "../src/db/prisma.js";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js"; import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import { seedReferenceData } from "../src/db/reference-seed-data.js"; import { seedReferenceData, TECH_STEPS, UTENSILS } from "../src/db/reference-seed-data.js";
import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js"; import type { RecipeSourceAdapter } from "../src/lib/recipe-sources/recipe-source-adapter.js";
import { import {
clearRecipeSources, clearRecipeSources,
@ -137,7 +137,9 @@ describe("Reference data", () => {
const res = await request(app).get("/reference/tech-steps"); const res = await request(app).get("/reference/tech-steps");
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body).to.have.length(26); // `TECH_STEPS.length` (reference-seed-data.ts), not a hardcoded
// number — this catalog has grown since (26 -> 74) and will again.
expect(res.body).to.have.length(TECH_STEPS.length);
expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer"); expect(res.body.map((t: { key: string }) => t.key)).to.include("simmer");
expect(res.body[0]).to.have.keys(["id", "key"]); expect(res.body[0]).to.have.keys(["id", "key"]);
}); });
@ -155,7 +157,32 @@ describe("Reference data", () => {
await seedReferenceData(prisma); await seedReferenceData(prisma);
const res = await request(app).get("/reference/tech-steps"); const res = await request(app).get("/reference/tech-steps");
expect(res.body).to.have.length(26); expect(res.body).to.have.length(TECH_STEPS.length);
});
});
describe("GET /reference/utensils", () => {
it("returns the seeded utensils, no session required", async () => {
const res = await request(app).get("/reference/utensils");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(UTENSILS.length);
expect(res.body.map((u: { key: string }) => u.key)).to.include("pan");
expect(res.body[0]).to.have.keys(["id", "key"]);
});
it("orders utensils alphabetically by key", async () => {
const res = await request(app).get("/reference/utensils");
const keys = res.body.map((u: { key: string }) => u.key);
expect(keys).to.deep.equal([...keys].sort());
});
it("reseeding is idempotent — no duplicate utensils", async () => {
await seedReferenceData(prisma);
const res = await request(app).get("/reference/utensils");
expect(res.body).to.have.length(UTENSILS.length);
}); });
}); });

View file

@ -0,0 +1,336 @@
import type { DateTime } from "@batch-cooking/date-tools";
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { TEST_REFERENCE_DATE } from "../test-support/reference-date.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** The fixed test "today", as the `YYYY-MM-DD` string `GET /shopping-list`'s `?date=` expects. */
function today(): string {
return isoDate(TEST_REFERENCE_DATE);
}
/** `toISODate()` only returns `null` for an invalid `DateTime` — never the case for the always-valid values built in this file. */
function isoDate(date: DateTime): string {
const iso = date.toISODate();
if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper");
return iso;
}
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helper as `recipe.test.ts`. */
async function ingredientId(key: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
return ingredient.id;
}
/** Resolves a reference unit's id by its `reference-seed-data.ts` uid — same helper as `recipe.test.ts`. */
async function unitId(key: string): Promise<number> {
const unit = await prisma.unit.findFirstOrThrow({ where: { key } });
return unit.id;
}
describe("Shopping list", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /shopping-list", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a missing date with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/shopping-list");
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects a malformed date with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/shopping-list").query({ date: "not-a-date" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects a date shaped right but calendarially impossible with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/shopping-list").query({ date: "2026-02-30" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("returns an empty list when the profile has no household", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.deep.equal([]);
});
it("returns an empty list when the household has no planning covering that date", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
await agent.post("/house").send({ name: "Chez moi" });
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.deep.equal([]);
});
it("sums one recipe's ingredient across two planning slots, scaled by each slot's own portions", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const tomatoId = await ingredientId("tomato");
const gramId = await unitId("gram");
// Written for 2 portions, 100g tomato — planned twice this week at
// 4 portions each, so the shopping list should show 100 × (4/2) × 2
// = 400g, not the raw 200g the recipe itself lists.
const recipe = await prisma.recipe.create({
data: {
name: "Salade de tomates",
authorId,
portions: 2,
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
},
});
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: [
{
planningId: planning.id,
weekDay: "lundi",
meal: "dejeuner",
recipeId: recipe.id,
portions: 4,
},
{
planningId: planning.id,
weekDay: "mercredi",
meal: "diner",
recipeId: recipe.id,
portions: 4,
},
],
});
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.have.length(1);
expect(res.body.items[0].ingredient.key).to.equal("tomato");
expect(res.body.items[0].unit.key).to.equal("gram");
expect(res.body.items[0].quantity).to.equal(400);
});
it("sums the same ingredient across two different recipes sharing a unit", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const onionId = await ingredientId("onion");
const gramId = await unitId("gram");
const recipeA = await prisma.recipe.create({
data: {
name: "Soupe à l'oignon",
authorId,
portions: 4,
ingredients: { create: [{ ingredientId: onionId, quantity: 200, unitId: gramId }] },
},
});
const recipeB = await prisma.recipe.create({
data: {
name: "Tarte à l'oignon",
authorId,
portions: 4,
ingredients: { create: [{ ingredientId: onionId, quantity: 150, unitId: gramId }] },
},
});
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: [
{
planningId: planning.id,
weekDay: "lundi",
meal: "dejeuner",
recipeId: recipeA.id,
portions: 4,
},
{
planningId: planning.id,
weekDay: "mardi",
meal: "diner",
recipeId: recipeB.id,
portions: 4,
},
],
});
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.have.length(1);
expect(res.body.items[0].ingredient.key).to.equal("onion");
expect(res.body.items[0].quantity).to.equal(350);
});
it("keeps the same ingredient in two different units as two separate lines", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const tomatoId = await ingredientId("tomato");
const gramId = await unitId("gram");
const kilogramId = await unitId("kilogram");
const recipeA = await prisma.recipe.create({
data: {
name: "Recette A",
authorId,
portions: 2,
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
},
});
const recipeB = await prisma.recipe.create({
data: {
name: "Recette B",
authorId,
portions: 2,
ingredients: { create: [{ ingredientId: tomatoId, quantity: 1, unitId: kilogramId }] },
},
});
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: [
{
planningId: planning.id,
weekDay: "lundi",
meal: "dejeuner",
recipeId: recipeA.id,
portions: 2,
},
{
planningId: planning.id,
weekDay: "mardi",
meal: "diner",
recipeId: recipeB.id,
portions: 2,
},
],
});
const res = await agent.get("/shopping-list").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.items).to.have.length(2);
const units = res.body.items.map((item: { unit: { key: string } }) => item.unit.key).sort();
expect(units).to.deep.equal(["gram", "kilogram"]);
});
it("returns a different week's shopping list when asked for a date outside the current one", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const tomatoId = await ingredientId("tomato");
const gramId = await unitId("gram");
const recipe = await prisma.recipe.create({
data: {
name: "Curry de lentilles",
authorId,
portions: 2,
ingredients: { create: [{ ingredientId: tomatoId, quantity: 100, unitId: gramId }] },
},
});
const nextWeek = TEST_REFERENCE_DATE.plus({ weeks: 1 });
const planning = await prisma.planning.create({
data: {
houseId,
startDate: nextWeek.startOf("week").toJSDate(),
finishDate: nextWeek.endOf("week").startOf("day").toJSDate(),
},
});
await prisma.planningItem.create({
data: {
planningId: planning.id,
weekDay: "mardi",
meal: "dejeuner",
recipeId: recipe.id,
portions: 2,
},
});
const nextWeekRes = await agent.get("/shopping-list").query({ date: isoDate(nextWeek) });
expect(nextWeekRes.body.items).to.have.length(1);
const thisWeekRes = await agent.get("/shopping-list").query({ date: today() });
expect(thisWeekRes.body.items).to.deep.equal([]);
});
});
});

View file

@ -25,6 +25,33 @@ describe("registerAllRecipeSources", () => {
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("theMealDb"); expect(listRecipeSources().map((adapter) => adapter.key)).to.include("theMealDb");
}); });
it("registers Marmiton into the shared registry", () => {
registerAllRecipeSources();
const marmiton = getRecipeSource("marmiton");
expect(marmiton).to.not.be.undefined;
expect(marmiton?.name).to.equal("Marmiton");
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("marmiton");
});
it("registers 750g into the shared registry", () => {
registerAllRecipeSources();
const sevenFiftyG = getRecipeSource("750g");
expect(sevenFiftyG).to.not.be.undefined;
expect(sevenFiftyG?.name).to.equal("750g");
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("750g");
});
it("registers Manger Bouger into the shared registry", () => {
registerAllRecipeSources();
const mangerBouger = getRecipeSource("mangerBouger");
expect(mangerBouger).to.not.be.undefined;
expect(mangerBouger?.name).to.equal("Manger Bouger");
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("mangerBouger");
});
it("does not register the generic JSON-LD adapter — it's not a household-toggleable source in its own right", () => { it("does not register the generic JSON-LD adapter — it's not a household-toggleable source in its own right", () => {
registerAllRecipeSources(); registerAllRecipeSources();

View file

@ -120,6 +120,55 @@ function buildDuplicateIngredientAdapter(key = "duplicateFakeSource"): RecipeSou
}; };
} }
/**
* A minimal French-content fake adapter same shape as {@link buildFakeAdapter},
* `locale: "fr"` instead of `"en"`. Exercises `previewSourceItem` actually
* resolving ingredients for a non-English source through the real HTTP
* endpoint/catalog: `loadIngredientCatalog`/`loadUnitCatalog` used to be
* called only for `locale === "en"`, silently leaving every ingredient
* unresolved for a French source like Marmiton/750g/Manger Bouger the
* regression this test guards against.
*/
function buildFrenchFakeAdapter(key = "fakeFrSource"): RecipeSourceAdapter<{ externalId: string }> {
return {
key,
name: "Fake French Source",
official: true,
iconUrl: null,
locale: "fr",
async list(_params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
return {
items: [
{ externalId: "1", title: "Soupe à l'oignon", picture: null, url: "https://fake.test/1" },
],
nextCursor: null,
};
},
async fetchDetail(externalId: string): Promise<{ externalId: string }> {
return { externalId };
},
parse(raw: { externalId: string }): ParsedRecipe {
return {
name: `Recette factice ${raw.externalId}`,
description: null,
picture: null,
portions: 4,
sourceUrl: `https://fake.test/${raw.externalId}`,
ingredients: [
{ rawText: "3 carottes", quantity: null, unit: null, name: "carottes" },
{
rawText: "un ingrédient mystère",
quantity: null,
unit: null,
name: "ingrédient mystère",
},
],
steps: [{ description: "Faire mijoter à feu doux", picture: null }],
};
},
};
}
/** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as `recipe.test.ts`'s own helper. */ /** Resolves a reference ingredient's id by its `reference-seed-data.ts` uid (also its DB `key`) — same reasoning as `recipe.test.ts`'s own helper. */
async function ingredientId(key: string): Promise<number> { async function ingredientId(key: string): Promise<number> {
const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } }); const ingredient = await prisma.ingredient.findFirstOrThrow({ where: { key } });
@ -305,6 +354,37 @@ describe("Sources", () => {
expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND); expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND);
}); });
it("translates a French-locale source's item too, resolving ingredients against the French catalog (previously only 'en' sources ever got matched)", async () => {
const { agent } = await signupWithHouse();
registerRecipeSource(buildFrenchFakeAdapter());
await syncRecipeSources(prisma);
const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeFrSource" } });
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
const carrot = await prisma.ingredient.findFirstOrThrow({ where: { key: "carrot" } });
const piece = await prisma.unit.findFirstOrThrow({ where: { key: "piece" } });
const simmer = await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } });
const res = await agent.get("/sources/fakeFrSource/preview/1");
expect(res.status).to.equal(200);
const [resolved, unresolved] = res.body.ingredients;
expect(resolved.rawText).to.equal("3 carottes");
expect(resolved.ingredient).to.deep.include({ id: carrot.id, key: "carrot" });
// No explicit unit word in "3 carottes" — falls back to the generic
// "piece" unit (see translateRecipeIngredients' own doc comment on
// issue #53), same as the English fake adapter's "1 onion" would.
expect(resolved.unit).to.deep.include({ id: piece.id, key: "piece" });
expect(resolved.quantity).to.equal(3);
expect(unresolved.rawText).to.equal("un ingrédient mystère");
expect(unresolved.ingredient).to.equal(null);
expect(res.body.steps).to.have.length(1);
expect(res.body.steps[0].techSteps[0].techStep).to.deep.equal({
id: simmer.id,
key: "simmer",
});
});
it("merges two lines that resolve to the same ingredient, summing their quantity (issue #53 follow-up)", async () => { it("merges two lines that resolve to the same ingredient, summing their quantity (issue #53 follow-up)", async () => {
const { agent } = await signupWithHouse(); const { agent } = await signupWithHouse();
registerRecipeSource(buildDuplicateIngredientAdapter()); registerRecipeSource(buildDuplicateIngredientAdapter());

View file

@ -0,0 +1,277 @@
import { useState } from "react";
import "../../src/i18n/i18n";
import { TechStepCorrectionPopover } from "../../src/features/recipes/steps/TechStepCorrectionPopover";
// Mounts the popover in isolation (no StepDescription/selection plumbing
// around it) — same "generic component test" posture as CheckboxOption.cy.tsx,
// but this one needs `../../src/i18n/i18n` imported for its side effect
// (initializes the default i18next instance `useTranslation` falls back to
// with no `<I18nextProvider>` in the tree — see that module's own doc
// comment) since, unlike Checkbox/Radio, this component calls
// `useTranslation()`.
const cook = { id: 1, key: "cook" };
const simmer = { id: 3, key: "simmer" };
const butter = { id: 10, key: "butter" };
const pan = { id: 20, key: "pan" };
const gram = { id: 30, key: "gram" };
/**
* A real `StepDescription` resolves `onRequestSpan` into a fresh
* `resolvedMetadataSpan` via an actual browser text selection out of
* scope for a component test of the popover alone (covered by the e2e
* scenario instead). This harness fakes that round-trip with a fixed
* span, so tests here can exercise everything the popover itself is
* responsible for once a span comes back, without needing a real
* `StepDescription` in the tree.
*/
function Harness({
previousTechStepId = null,
existingIngredients = [],
existingUtensils = [],
onClose = () => {},
onSubmitted = () => {},
}: Partial<{
previousTechStepId: number | null;
existingIngredients: unknown[];
existingUtensils: unknown[];
onClose: () => void;
onSubmitted: (result: unknown) => void;
}>) {
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
nonce: number;
kind: "ingredient" | "utensil";
range: { start: number; end: number };
text: string;
} | null>(null);
return (
<div>
{/* A genuinely separate sibling to click for the "outside click closes it" test — clicking blindly at a viewport coordinate would risk still landing inside the popover, which fills most of the mounted area on its own. */}
<div data-testid="outside-popover" style={{ height: 20 }} />
<TechStepCorrectionPopover
recipeId={2}
stepId={2}
selectedText="Cuire"
range={{ start: 0, end: 5 }}
previousTechStepId={previousTechStepId}
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView props — precise typing isn't the point here.
existingIngredients={existingIngredients as any}
// biome-ignore lint/suspicious/noExplicitAny: see above.
existingUtensils={existingUtensils as any}
resolvedMetadataSpan={resolvedMetadataSpan}
onRequestSpan={(kind) =>
setResolvedMetadataSpan({
nonce: Date.now(),
kind,
range: { start: 20, end: 26 },
text: "Beurre",
})
}
onClose={onClose}
// biome-ignore lint/suspicious/noExplicitAny: see above.
onSubmitted={onSubmitted as any}
/>
</div>
);
}
describe("TechStepCorrectionPopover", () => {
beforeEach(() => {
cy.intercept("GET", "**/reference/tech-steps", { statusCode: 200, body: [cook, simmer] }).as(
"getTechSteps",
);
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [butter] }).as(
"getIngredients",
);
cy.intercept("GET", "**/reference/units", { statusCode: 200, body: [gram] }).as("getUnits");
cy.intercept("GET", "**/reference/utensils", { statusCode: 200, body: [pan] }).as(
"getUtensils",
);
});
it("shows the selected text, the technique catalog (searchable) and the metadata sections all together", () => {
cy.mount(<Harness />);
cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
// Merged editor (see TechStepCorrectionPopover's own doc comment) — no
// separate "pick, then metadata reveals itself" step, both render at
// once, and the technique catalog goes through the same searchable
// `CatalogSearchPicker` as the ingredient/utensil sub-flows (a plain
// unfiltered list of the real ~74-entry catalog isn't browsable).
cy.get(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
).should("have.length", 2);
cy.contains("h4", "Ingrédients").should("be.visible");
cy.contains("h4", "Ustensiles").should("be.visible");
cy.contains("button", "Valider").should("be.visible");
});
it("offers a 'no technique here' option, and marks the current pick, only when correcting an existing match", () => {
cy.mount(<Harness previousTechStepId={null} />);
cy.wait("@getTechSteps");
cy.get(".tech-step-correction-popover__remove").should("not.exist");
cy.contains(".tech-step-correction-popover__chosen-technique", "Aucune technique sélectionnée");
cy.mount(<Harness previousTechStepId={cook.id} />);
cy.wait("@getTechSteps");
cy.get(".tech-step-correction-popover__remove").should("exist");
cy.contains(".tech-step-correction-popover__chosen-technique", "Cuire");
cy.contains(".catalog-search-picker__list button", "Cuire").should(
"have.class",
"catalog-search-picker__item--selected",
);
});
it("picking a technique from the catalog selects it without submitting immediately", () => {
cy.mount(<Harness />);
cy.wait("@getTechSteps");
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains(".tech-step-correction-popover__chosen-technique", "Mijoter");
cy.contains("button", "Valider").should("be.visible");
});
it("Valider stays disabled until a technique is actually picked", () => {
cy.mount(<Harness />);
cy.wait("@getTechSteps");
cy.contains("button", "Valider").should("be.disabled");
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains("button", "Valider").should("not.be.disabled");
});
it("submits the selected technique (no metadata touched) with ingredients/utensils omitted from the request", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201,
body: {
id: 1,
start: 0,
end: 5,
previousTechStep: null,
correctedTechStep: simmer,
createdAt: new Date().toISOString(),
},
}).as("submitCorrection");
const onSubmitted = cy.stub().as("onSubmitted");
cy.mount(<Harness onSubmitted={onSubmitted} />);
cy.wait("@getTechSteps");
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains("button", "Valider").click();
cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
start: 0,
end: 5,
previousTechStepId: null,
correctedTechStepId: simmer.id,
});
cy.get("@onSubmitted").should("have.been.calledOnce");
});
it("adds an ingredient with quantity/unit via the span-selection flow, included in the submitted request", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201,
body: {
id: 1,
start: 0,
end: 5,
previousTechStep: null,
correctedTechStep: simmer,
createdAt: new Date().toISOString(),
},
}).as("submitCorrection");
cy.mount(<Harness />);
cy.wait("@getTechSteps");
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains("button", "+ Ajouter un ingrédient").click();
cy.contains(".catalog-search-picker button", "Beurre").click();
cy.get('input[type="number"]').type("50");
cy.get("select").select(String(gram.id));
cy.contains("button", "Ajouter").click();
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").should("be.visible");
cy.contains("button", "Valider").click();
cy.wait("@submitCorrection")
.its("request.body")
.should("deep.equal", {
start: 0,
end: 5,
previousTechStepId: null,
correctedTechStepId: simmer.id,
ingredients: [
{ ingredientId: butter.id, quantity: 50, unitId: gram.id, start: 20, end: 26 },
],
utensils: [],
});
});
it("pre-seeds existing ingredients/utensils, removable via their own chip", () => {
cy.mount(
<Harness
previousTechStepId={cook.id}
existingIngredients={[
{ ingredient: butter, quantity: 50, unit: gram, start: 0, end: 6, source: "auto" },
]}
existingUtensils={[{ utensil: pan, start: 14, end: 23, source: "auto" }]}
/>,
);
// An existing match starts pre-selected on itself (see
// TechStepCorrectionPopover's own doc comment) — the metadata sections,
// pre-seeded from `existingIngredients`/`existingUtensils`, are visible
// immediately, no need to re-pick "Cuire" from a list first.
cy.wait("@getTechSteps");
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").find("button").click();
cy.contains(".tech-step-correction-popover__chip", "Beurre").should("not.exist");
});
it("shows an error message and stays open when the submission fails", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 404,
body: { code: 4051, message: "TechStep not found" },
}).as("submitCorrection");
const onClose = cy.stub().as("onClose");
cy.mount(<Harness onClose={onClose} />);
cy.wait("@getTechSteps");
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Cuire",
).click();
cy.contains("button", "Valider").click();
cy.wait("@submitCorrection");
cy.get(".field-error").should("be.visible");
cy.get("@onClose").should("not.have.been.called");
});
it("calls onClose on an outside click", () => {
const onClose = cy.stub().as("onClose");
cy.mount(<Harness onClose={onClose} />);
cy.wait("@getTechSteps");
cy.get('[data-testid="outside-popover"]').click();
cy.get("@onClose").should("have.been.calledOnce");
});
});

View file

@ -6,18 +6,20 @@ import { splitDescriptionByTechSteps } from "../../src/features/recipes/steps/hi
// `expect`, not for rendering. `.cy.tsx` (not `.cy.ts`) only because that's // `expect`, not for rendering. `.cy.tsx` (not `.cy.ts`) only because that's
// what `cypress.config.ts`'s component `specPattern` looks for. // what `cypress.config.ts`'s component `specPattern` looks for.
/** Builds a `StepTechStepView` — `context` omitted entirely (not just undefined) when absent, matching what the API actually sends for an older, not-yet-recomputed match (see `StepTechStepView`'s own doc comment). */ /** Builds a `StepTechStepView` — `context` omitted entirely (not just undefined) when absent, matching what the API actually sends for an older, not-yet-recomputed match (see `StepTechStepView`'s own doc comment). `source` defaults to `"auto"`, the common case every test not specifically about the manual/auto distinction uses. */
function techStep( function techStep(
key: string, key: string,
id: number, id: number,
start: number, start: number,
end: number, end: number,
context?: { start: number; end: number }, context?: { start: number; end: number },
source: StepTechStepView["source"] = "auto",
): StepTechStepView { ): StepTechStepView {
return { return {
techStep: { id, key }, techStep: { id, key },
start, start,
end, end,
source,
...(context ? { contextStart: context.start, contextEnd: context.end } : {}), ...(context ? { contextStart: context.start, contextEnd: context.end } : {}),
}; };
} }
@ -25,7 +27,7 @@ function techStep(
describe("splitDescriptionByTechSteps", () => { describe("splitDescriptionByTechSteps", () => {
it("returns the whole description as one plain segment when there are no matches", () => { it("returns the whole description as one plain segment when there are no matches", () => {
expect(splitDescriptionByTechSteps("Servir immédiatement", [])).to.deep.equal([ expect(splitDescriptionByTechSteps("Servir immédiatement", [])).to.deep.equal([
{ text: "Servir immédiatement", techStep: null, isKeyword: false }, { text: "Servir immédiatement", techStep: null, isKeyword: false, source: null },
]); ]);
}); });
@ -37,25 +39,25 @@ describe("splitDescriptionByTechSteps", () => {
techStep("simmer", 1, 6, 13), techStep("simmer", 1, 6, 13),
]); ]);
expect(result).to.deep.equal([ expect(result).to.deep.equal([
{ text: "Faire ", techStep: null, isKeyword: false }, { text: "Faire ", techStep: null, isKeyword: false, source: null },
{ text: "mijoter", techStep: { id: 1, key: "simmer" }, isKeyword: true }, { text: "mijoter", techStep: { id: 1, key: "simmer" }, isKeyword: true, source: "auto" },
{ text: " à feu doux", techStep: null, isKeyword: false }, { text: " à feu doux", techStep: null, isKeyword: false, source: null },
]); ]);
}); });
it("handles a match at the very start, with nothing before it", () => { it("handles a match at the very start, with nothing before it", () => {
const result = splitDescriptionByTechSteps("Hacher les oignons", [techStep("chop", 2, 0, 6)]); const result = splitDescriptionByTechSteps("Hacher les oignons", [techStep("chop", 2, 0, 6)]);
expect(result).to.deep.equal([ expect(result).to.deep.equal([
{ text: "Hacher", techStep: { id: 2, key: "chop" }, isKeyword: true }, { text: "Hacher", techStep: { id: 2, key: "chop" }, isKeyword: true, source: "auto" },
{ text: " les oignons", techStep: null, isKeyword: false }, { text: " les oignons", techStep: null, isKeyword: false, source: null },
]); ]);
}); });
it("handles a match at the very end, with nothing after it", () => { it("handles a match at the very end, with nothing after it", () => {
const result = splitDescriptionByTechSteps("Faire cuire", [techStep("cook", 3, 6, 11)]); const result = splitDescriptionByTechSteps("Faire cuire", [techStep("cook", 3, 6, 11)]);
expect(result).to.deep.equal([ expect(result).to.deep.equal([
{ text: "Faire ", techStep: null, isKeyword: false }, { text: "Faire ", techStep: null, isKeyword: false, source: null },
{ text: "cuire", techStep: { id: 3, key: "cook" }, isKeyword: true }, { text: "cuire", techStep: { id: 3, key: "cook" }, isKeyword: true, source: "auto" },
]); ]);
}); });
@ -71,6 +73,7 @@ describe("splitDescriptionByTechSteps", () => {
text: "Préchauffer", text: "Préchauffer",
techStep: { id: 4, key: "preheat" }, techStep: { id: 4, key: "preheat" },
isKeyword: true, isKeyword: true,
source: "auto",
}); });
}); });
@ -88,17 +91,23 @@ describe("splitDescriptionByTechSteps", () => {
it("drops a match whose end is past the end of the description", () => { it("drops a match whose end is past the end of the description", () => {
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 0, 999)]); const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 0, 999)]);
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]); expect(result).to.deep.equal([
{ text: "Cuire", techStep: null, isKeyword: false, source: null },
]);
}); });
it("drops a match with a negative start", () => { it("drops a match with a negative start", () => {
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, -1, 5)]); const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, -1, 5)]);
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]); expect(result).to.deep.equal([
{ text: "Cuire", techStep: null, isKeyword: false, source: null },
]);
}); });
it("drops a match whose start isn't before its end", () => { it("drops a match whose start isn't before its end", () => {
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 3, 3)]); const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 3, 3)]);
expect(result).to.deep.equal([{ text: "Cuire", techStep: null, isKeyword: false }]); expect(result).to.deep.equal([
{ text: "Cuire", techStep: null, isKeyword: false, source: null },
]);
}); });
it("drops a later match that overlaps one already accepted", () => { it("drops a later match that overlaps one already accepted", () => {
@ -110,7 +119,7 @@ describe("splitDescriptionByTechSteps", () => {
techStep("cook", 2, 0, 5), techStep("cook", 2, 0, 5),
]); ]);
expect(result).to.deep.equal([ expect(result).to.deep.equal([
{ text: "Cuire au four", techStep: { id: 3, key: "bake" }, isKeyword: true }, { text: "Cuire au four", techStep: { id: 3, key: "bake" }, isKeyword: true, source: "auto" },
]); ]);
}); });
@ -118,6 +127,42 @@ describe("splitDescriptionByTechSteps", () => {
expect(splitDescriptionByTechSteps("", [])).to.deep.equal([]); expect(splitDescriptionByTechSteps("", [])).to.deep.equal([]);
}); });
it("carries a manual correction's source through its segments, distinct from an auto match", () => {
const text = "Faire mijoter le riz, puis dresser dans les assiettes";
const result = splitDescriptionByTechSteps(text, [
techStep("simmer", 1, 6, 13),
techStep("plate", 2, 28, 35, undefined, "manual"),
]);
const keywordSegments = result.filter((s) => s.isKeyword);
expect(keywordSegments.map((s) => ({ key: s.techStep?.key, source: s.source }))).to.deep.equal([
{ key: "simmer", source: "auto" },
{ key: "plate", source: "manual" },
]);
});
it("never lets one match's wider context swallow another match's own keyword span", () => {
// The motivating real bug (found via live testing, not invented for
// this test): "simmer" is the only NER candidate `splitIntoClauses`
// found, so its context spans the *entire* description — before this
// was fixed, that wide context advanced `cursor` past 39, silently
// dropping "setAside"'s own keyword span (a manual correction on
// "materiel", a word with no relation to "simmer" at all) instead of
// rendering it.
const text = "Faire mijoter la sauce, puis ranger le materiel.";
const result = splitDescriptionByTechSteps(text, [
techStep("simmer", 1, 6, 13, { start: 0, end: 48 }),
techStep("setAside", 2, 39, 47, undefined, "manual"),
]);
const keywordSegments = result.filter((s) => s.isKeyword);
expect(
keywordSegments.map((s) => ({ key: s.techStep?.key, text: s.text, source: s.source })),
).to.deep.equal([
{ key: "simmer", text: "mijoter", source: "auto" },
{ key: "setAside", text: "materiel", source: "manual" },
]);
expect(result.map((s) => s.text).join("")).to.equal(text);
});
describe("with a context span wider than the keyword", () => { describe("with a context span wider than the keyword", () => {
it("splits into context-before / keyword / context-after around a keyword in the middle of its clause", () => { it("splits into context-before / keyword / context-after around a keyword in the middle of its clause", () => {
// The motivating example: "Dans une poêle chaude, faire chauffer une // The motivating example: "Dans une poêle chaude, faire chauffer une
@ -128,12 +173,23 @@ describe("splitDescriptionByTechSteps", () => {
techStep("preheat", 4, 9, 21, { start: 0, end: 21 }), techStep("preheat", 4, 9, 21, { start: 0, end: 21 }),
]); ]);
expect(result).to.deep.equal([ expect(result).to.deep.equal([
{ text: "Dans une ", techStep: { id: 4, key: "preheat" }, isKeyword: false }, {
{ text: "poêle chaude", techStep: { id: 4, key: "preheat" }, isKeyword: true }, text: "Dans une ",
techStep: { id: 4, key: "preheat" },
isKeyword: false,
source: "auto",
},
{
text: "poêle chaude",
techStep: { id: 4, key: "preheat" },
isKeyword: true,
source: "auto",
},
{ {
text: ", faire chauffer une noix de beurre", text: ", faire chauffer une noix de beurre",
techStep: null, techStep: null,
isKeyword: false, isKeyword: false,
source: null,
}, },
]); ]);
}); });
@ -143,8 +199,13 @@ describe("splitDescriptionByTechSteps", () => {
techStep("preheat", 4, 0, 11, { start: 0, end: 19 }), techStep("preheat", 4, 0, 11, { start: 0, end: 19 }),
]); ]);
expect(result).to.deep.equal([ expect(result).to.deep.equal([
{ text: "préchauffer", techStep: { id: 4, key: "preheat" }, isKeyword: true }, {
{ text: " le four", techStep: { id: 4, key: "preheat" }, isKeyword: false }, text: "préchauffer",
techStep: { id: 4, key: "preheat" },
isKeyword: true,
source: "auto",
},
{ text: " le four", techStep: { id: 4, key: "preheat" }, isKeyword: false, source: "auto" },
]); ]);
}); });
@ -153,9 +214,19 @@ describe("splitDescriptionByTechSteps", () => {
techStep("preheat", 4, 17, 28, { start: 7, end: 28 }), techStep("preheat", 4, 17, 28, { start: 7, end: 28 }),
]); ]);
expect(result).to.deep.equal([ expect(result).to.deep.equal([
{ text: "mettre ", techStep: null, isKeyword: false }, { text: "mettre ", techStep: null, isKeyword: false, source: null },
{ text: "le four à ", techStep: { id: 4, key: "preheat" }, isKeyword: false }, {
{ text: "préchauffer", techStep: { id: 4, key: "preheat" }, isKeyword: true }, text: "le four à ",
techStep: { id: 4, key: "preheat" },
isKeyword: false,
source: "auto",
},
{
text: "préchauffer",
techStep: { id: 4, key: "preheat" },
isKeyword: true,
source: "auto",
},
]); ]);
}); });
@ -171,7 +242,9 @@ describe("splitDescriptionByTechSteps", () => {
// contextEnd (5) is before the keyword's own end (13) — malformed. // contextEnd (5) is before the keyword's own end (13) — malformed.
techStep("bake", 3, 0, 13, { start: 0, end: 5 }), techStep("bake", 3, 0, 13, { start: 0, end: 5 }),
]); ]);
expect(result).to.deep.equal([{ text: "Cuire au four", techStep: null, isKeyword: false }]); expect(result).to.deep.equal([
{ text: "Cuire au four", techStep: null, isKeyword: false, source: null },
]);
}); });
}); });
}); });

View file

@ -147,11 +147,13 @@ describe("Page width — full-bleed pages vs. centered reading columns (#21 regr
// that cap was dropped so they fill the width like every other page. // that cap was dropped so they fill the width like every other page.
cy.visit("/parametres/compte"); cy.visit("/parametres/compte");
assertFillsContentWidth(".settings-page"); assertFillsContentWidth(".settings-page");
});
it("centers the Liste de courses stub, with equal space on both sides", () => { cy.intercept("GET", /\/shopping-list\?/, {
statusCode: 200,
body: { startDate: "2026-08-17", finishDate: "2026-08-23", items: [] },
});
cy.visit("/liste-de-courses"); cy.visit("/liste-de-courses");
assertCenteredColumn(".coming-soon-page", 640); // max-width: 40rem assertFillsContentWidth(".shopping-list-page");
}); });
/** Fills `.app-content`'s available (padding-excluded) width, within a couple px of scrollbar/rounding slack. */ /** Fills `.app-content`'s available (padding-excluded) width, within a couple px of scrollbar/rounding slack. */
@ -168,22 +170,6 @@ describe("Page width — full-bleed pages vs. centered reading columns (#21 regr
}); });
}); });
} }
/** Capped at `maxWidthPx` (not stretched full-bleed) and horizontally centered — equal left/right gap within `.app-content`. */
function assertCenteredColumn(selector: string, maxWidthPx: number) {
cy.get(".app-content").then(($content) => {
const contentRect = $content[0].getBoundingClientRect();
cy.get(selector).should(($page) => {
const pageRect = $page[0].getBoundingClientRect();
expect(pageRect.width).to.be.closeTo(maxWidthPx, 2);
const leftGap = pageRect.left - contentRect.left;
const rightGap = contentRect.right - pageRect.right;
expect(leftGap).to.be.closeTo(rightGap, 2);
});
});
}
}); });
describe("Responsive breakpoint — sidebar becomes a horizontal top bar under 640px", () => { describe("Responsive breakpoint — sidebar becomes a horizontal top bar under 640px", () => {

View file

@ -33,7 +33,7 @@ describe("Sidebar navigation", () => {
// The Foyer/Compte/Préférences links — behind the sidebar's "Paramètres" // The Foyer/Compte/Préférences links — behind the sidebar's "Paramètres"
// toggle, not the main nav tested here — are covered by sidebar.cy.ts. // toggle, not the main nav tested here — are covered by sidebar.cy.ts.
it("highlights the current section and navigates between stub pages", () => { it("highlights the current section and navigates between pages", () => {
cy.contains("nav a", "Planning").should("have.class", "active"); cy.contains("nav a", "Planning").should("have.class", "active");
cy.contains("nav a", "Recettes").click(); cy.contains("nav a", "Recettes").click();

View file

@ -45,6 +45,29 @@ Feature: Browsing external recipe sources
And the recipe detail panel heading should be "Fish Pie" And the recipe detail panel heading should be "Fish Pie"
And I should see the highlighted technique "Cuire" And I should see the highlighted technique "Cuire"
Scenario: Loads further pages automatically, with no "load more" button
Given the recipe catalog contains nothing
And the sources reference list has options
And the household has enabled TheMealDB
And browsing TheMealDB returns two pages of items
When I visit "/recettes"
And I click the button "TheMealDB"
Then I should see the source item "Chicken Handi"
And I should see the source item "Beef Wellington"
And I should not see "Voir plus"
Scenario: Offers a retry when loading the next page fails
Given the recipe catalog contains nothing
And the sources reference list has options
And the household has enabled TheMealDB
And browsing TheMealDB's next page fails once, then succeeds
When I visit "/recettes"
And I click the button "TheMealDB"
Then I should see the source item "Chicken Handi"
And I should see a message to retry loading more
When I click the button "Réessayer"
Then I should see the source item "Beef Wellington"
Scenario: Deep-links straight to a not-yet-imported item's own page, with no import affordance at all Scenario: Deep-links straight to a not-yet-imported item's own page, with no import affordance at all
Given the recipe catalog contains nothing Given the recipe catalog contains nothing
And the sources reference list has options And the sources reference list has options

View file

@ -83,6 +83,81 @@ Given("browsing TheMealDB returns some items", () => {
}); });
}); });
/**
* A second, distinct item from `Given("browsing TheMealDB returns some
* items")`'s page-1 pair — used by the infinite-scroll/retry scenarios
* below, which need to tell "the item that only shows up once the *next*
* page has loaded" apart from what's already visible on page 1.
*/
const BEEF_WELLINGTON_ITEM = {
externalId: "77123",
title: "Beef Wellington",
picture: null,
url: "https://www.themealdb.com/meal/77123",
alreadyImported: false,
recipeId: null,
};
const CHICKEN_HANDI_AND_FISH_PIE_PAGE_1 = {
items: [
{
externalId: "52795",
title: "Chicken Handi",
picture: null,
url: "https://www.themealdb.com/meal/52795",
alreadyImported: true,
recipeId: 2,
},
{
externalId: "9999",
title: "Fish Pie",
picture: null,
url: "https://www.themealdb.com/meal/9999",
alreadyImported: false,
recipeId: null,
},
],
nextCursor: "2",
};
Given("browsing TheMealDB returns two pages of items", () => {
cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => {
const isNextPage = req.url.includes("cursor=");
req.reply({
statusCode: 200,
body: isNextPage
? { items: [BEEF_WELLINGTON_ITEM], nextCursor: null }
: CHICKEN_HANDI_AND_FISH_PIE_PAGE_1,
});
});
});
// The panel prefetches the next page as soon as page 1 is on screen (before
// anyone's actually waited on it), so the *first* request for it is that
// prefetch — this is what actually fails "once", not a request triggered by
// a click. `handleLoadMore`'s own retry then makes a genuinely fresh
// request (see its own doc comment on why a failed prefetch gets cleared),
// which is the one that succeeds here.
Given("browsing TheMealDB's next page fails once, then succeeds", () => {
let nextPageAttempts = 0;
cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => {
if (!req.url.includes("cursor=")) {
req.reply({ statusCode: 200, body: CHICKEN_HANDI_AND_FISH_PIE_PAGE_1 });
return;
}
nextPageAttempts += 1;
if (nextPageAttempts === 1) {
req.reply({ statusCode: 500, body: {} });
} else {
req.reply({ statusCode: 200, body: { items: [BEEF_WELLINGTON_ITEM], nextCursor: null } });
}
});
});
Then("I should see a message to retry loading more", () => {
cy.contains(".recipes-page__status--error", "Réessayer").should("be.visible");
});
Given("previewing TheMealDB item {string} is available", (externalId: string) => { Given("previewing TheMealDB item {string} is available", (externalId: string) => {
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, { cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
statusCode: 200, statusCode: 200,

View file

@ -27,6 +27,24 @@ Feature: Managing a recipe from the catalog
When I focus the highlighted technique "Cuire" When I focus the highlighted technique "Cuire"
Then the tooltip should show "Cuire" Then the tooltip should show "Cuire"
Scenario: Corrects a detected technique from its highlight, visible immediately as a manual match
Given the recipe catalog contains "Omelette"
And recipe 2's detail is available
And the tech steps reference list has options
And correcting step 2's "Cuire" match will succeed
When I visit "/recettes/2"
And I click the highlighted technique "Cuire"
Then I should see the technique correction options
When I choose "Mijoter" as the correct technique
Then the correction request should have been made
# The highlighted *word* stays "Cuire" (a relabel changes which
# technique a span means, not the literal text at that span, still
# "Cuire" in the source description) — now styled as a manual
# correction, with its tooltip naming the newly-assigned technique.
And the highlighted technique "Cuire" should be marked as a manual correction
When I focus the highlighted technique "Cuire"
Then the tooltip should show "Mijoter (correction manuelle)"
Scenario: Deletes a recipe after a two-step confirmation, then clears the selection Scenario: Deletes a recipe after a two-step confirmation, then clears the selection
Given the recipe catalog contains "Omelette" Given the recipe catalog contains "Omelette"
And recipe 2's detail is available And recipe 2's detail is available

View file

@ -43,7 +43,7 @@ const omeletteDetail = {
// "Cuire" -> the `cook` technique, matching real reference-seed-data.ts // "Cuire" -> the `cook` technique, matching real reference-seed-data.ts
// (`\bcui(re|sez|sant|sson)\b`) — "poêle" itself matches nothing // (`\bcui(re|sez|sant|sson)\b`) — "poêle" itself matches nothing
// (that's `panFry`'s "sauter", a different word). // (that's `panFry`'s "sauter", a different word).
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }], techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5, source: "auto" }],
}, },
], ],
}; };
@ -68,6 +68,84 @@ Given("deleting recipe 2 will succeed", () => {
cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe"); cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe");
}); });
// Step 2 is `omeletteDetail`'s "Cuire à la poêle." step, whose only
// existing match is `cook` (id 1) — see that fixture above. The response
// mirrors `SubmitTechStepCorrectionResult` (packages/shared): the audit
// record (reassigning the match to `simmer`, id 3, "Mijoter" — see `the
// tech steps reference list has options`, reference-data.steps.ts) plus
// the step's fresh `techSteps`, now showing that same reassignment as a
// `"manual"`-sourced entry — the API applies a correction immediately, it
// doesn't just record it (see `StepTechStepView.source`'s own doc comment).
Given('correcting step 2\'s "Cuire" match will succeed', () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201,
body: {
correction: {
id: 1,
start: 0,
end: 5,
previousTechStep: { id: 1, key: "cook" },
correctedTechStep: { id: 3, key: "simmer" },
createdAt: new Date().toISOString(),
},
techSteps: [
{
techStep: { id: 3, key: "simmer" },
start: 0,
end: 5,
source: "manual",
ingredients: [],
utensils: [],
},
],
},
}).as("correction");
});
When("I click the highlighted technique {string}", (text: string) => {
cy.contains(".step-tech-step", text).click();
});
Then("I should see the technique correction options", () => {
cy.get(".tech-step-correction-popover").should("be.visible");
});
// Picking a technique only *selects* it — it takes a separate "Valider"
// click to actually submit (room was made for attaching ingredient/utensil
// metadata alongside it, see `TechStepCorrectionPopover.tsx`'s own doc
// comment on its merged editor) — folded into this one step since nothing
// in this scenario cares about that intermediate state on its own. The
// technique catalog is picked via the same searchable `CatalogSearchPicker`
// the ingredient/utensil sub-flows use, scoped to
// `__technique-section` since that same search-and-pick component is
// reused inside this popover for more than just techniques.
When("I choose {string} as the correct technique", (label: string) => {
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
label,
).click();
cy.contains(".tech-step-correction-popover__confirm-button", "Valider").click();
});
Then(
"the highlighted technique {string} should be marked as a manual correction",
(text: string) => {
cy.contains(".step-tech-step", text).should("have.class", "step-tech-step--manual");
},
);
Then("the correction request should have been made", () => {
// Asserts the actual span, not just that *a* request fired — a real bug
// (StepDescription.tsx's click handler reading a shared, still-mutating
// `offset` variable by reference instead of a value captured at render
// time) once sent `end` all the way to the end of the description
// instead of "Cuire"'s own tight [0, 5) span, and a request-fired-only
// assertion here didn't catch it — found only via manual testing.
cy.wait("@correction")
.its("request.body")
.should("deep.include", { start: 0, end: 5, previousTechStepId: 1 });
});
Then("the recipe {string} should not be visible in the table", (name: string) => { Then("the recipe {string} should not be visible in the table", (name: string) => {
cy.contains(".recipe-table__name", name).should("not.exist"); cy.contains(".recipe-table__name", name).should("not.exist");
}); });

View file

@ -0,0 +1,32 @@
Feature: Shopping list
As a signed-in user
I want to see every ingredient needed for this week's planned recipes, already summed
So that I know what to buy without recomputing it myself
Background:
Given I am signed in as "Alice" "Martin"
And today is frozen at "2026-08-17T09:00:00.000Z"
Scenario: Nothing planned this week shows the empty message, not an error
Given the shopping list for "2026-08-17" is empty
When I visit "/liste-de-courses"
Then I should see "Aucun ingrédient à acheter pour cette semaine ajoutez des recettes à votre planning."
Scenario: Ingredients are grouped by aisle, in canonical order, each with its summed quantity
Given the shopping list for "2026-08-17" contains:
| ingredientKey | icon | category | quantity | unitKey |
| egg | EGG | dairyAndCheese | 6 | piece |
| tomato | VEGETABLE | freshProduce | 400 | gram |
When I visit "/liste-de-courses"
Then the shopping list group "Produits frais" should appear before "Crémerie & fromage"
And the shopping list should show "Tomate" at quantity "400 g"
And the shopping list should show "Oeuf" at quantity "6 unité"
Scenario: Navigating to another week fetches and shows that week's own list
Given the shopping list for "2026-08-17" is empty
And the shopping list for "2026-08-24" contains:
| ingredientKey | icon | category | quantity | unitKey |
| onion | VEGETABLE | freshProduce | 1 | kilogram |
When I visit "/liste-de-courses"
And I click the next week arrow
Then the shopping list should show "Oignon" at quantity "1 kg"

View file

@ -0,0 +1,72 @@
import { type DataTable, Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
/**
* One data-table row a fake `ShoppingListItemView` same minimal-fixture
* convention as `recipe-form.ts`'s ingredient fixtures (only the fields
* `ShoppingListPage` actually reads at runtime: the ingredient's `key`/
* `icon`/`category` for `IngredientTypeIcon`/`CategoryIcon`/translation, the
* unit's `key`; `id` only needs to be unique per row for the React list
* key). `index` seeds both ids so two rows never collide.
*/
function buildShoppingListItem(
row: { ingredientKey: string; icon: string; category: string; quantity: string; unitKey: string },
index: number,
) {
return {
ingredient: {
id: index,
key: row.ingredientKey,
icon: row.icon,
category: row.category,
subcategory: row.category,
reproducible: false,
allergens: [],
diets: [],
},
quantity: Number(row.quantity),
unit: { id: index, key: row.unitKey, type: "MASS", toBaseFactor: 1 },
};
}
Given("the shopping list for {string} is empty", (date: string) => {
cy.intercept("GET", `**/shopping-list?date=${date}`, {
statusCode: 200,
body: { startDate: date, finishDate: date, items: [] },
});
});
Given("the shopping list for {string} contains:", (date: string, dataTable: DataTable) => {
const items = dataTable.hashes().map((row, i) => buildShoppingListItem(row, i + 1));
cy.intercept("GET", `**/shopping-list?date=${date}`, {
statusCode: 200,
body: { startDate: date, finishDate: date, items },
});
});
// Same class as PlanningPage's own week navigator (`WeekNavigator`, now
// shared between the two pages) — `planning-page.cy.ts` already exercises
// the prev/next arrows directly by class, same approach here.
When("I click the next week arrow", () => {
cy.get(".week-nav__arrow").last().click();
});
Then(
"the shopping list group {string} should appear before {string}",
(first: string, second: string) => {
cy.get(".shopping-list__group-title").then(($titles) => {
const texts = [...$titles].map((el) => el.textContent?.trim() ?? "");
const firstIndex = texts.findIndex((text) => text.includes(first));
const secondIndex = texts.findIndex((text) => text.includes(second));
expect(firstIndex, `"${first}" should be a rendered group`).to.be.greaterThan(-1);
expect(secondIndex, `"${second}" should be a rendered group`).to.be.greaterThan(-1);
expect(firstIndex).to.be.lessThan(secondIndex);
});
},
);
Then(
"the shopping list should show {string} at quantity {string}",
(name: string, quantity: string) => {
cy.contains(".shopping-list__item", name).should("contain.text", quantity);
},
);

View file

@ -40,6 +40,21 @@ Given("the sources reference list is empty", () => {
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] }); cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
}); });
// `id`/`key` pairs mirror `recipes.ts`'s `omeletteDetail` fixture (`cook`,
// id 1, is the step's existing match) plus a second option
// (`simmer`/"Mijoter") for `recipes.feature`'s correction scenario to
// re-assign to — TechStepCorrectionPopover's own picker needs at least two
// choices for that scenario to be a meaningful correction, not a no-op.
Given("the tech steps reference list has options", () => {
cy.intercept("GET", "**/reference/tech-steps", {
statusCode: 200,
body: [
{ id: 1, key: "cook" },
{ id: 3, key: "simmer" },
],
});
});
// The real first entry (`theMealDb`) mirrors what's actually seeded // The real first entry (`theMealDb`) mirrors what's actually seeded
// (`reference-seed-data.ts`'s `registerAllRecipeSources`/ // (`reference-seed-data.ts`'s `registerAllRecipeSources`/
// `syncRecipeSources`); the second is illustrative only — a future // `syncRecipeSources`); the second is illustrative only — a future

View file

@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "echo \"no unit tests yet\" && exit 0", "test": "echo \"no unit tests yet\" && exit 0",

View file

@ -17,11 +17,17 @@ import {
type RecipeTab, type RecipeTab,
type RecipeView, type RecipeView,
type SafeUserProfile, type SafeUserProfile,
type ShoppingListView,
type SignupInput, type SignupInput,
type SourceView, type SourceView,
type StepTechStepCorrectionView,
type SubmitTechStepCorrectionInput,
type SubmitTechStepCorrectionResult,
type TechStepView,
type ThemePreference, type ThemePreference,
type UnitView, type UnitView,
type UpdateRecipeInput, type UpdateRecipeInput,
type UtensilView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
/** /**
@ -159,6 +165,18 @@ export class ApiClient {
return this._request(`/planning/items/${id}`, { method: "DELETE" }); return this._request(`/planning/items/${id}`, { method: "DELETE" });
} }
/**
* Fetches the current user's household's shopping list for the week
* covering `date` (`YYYY-MM-DD`, e.g. from `date-tools`'s
* `formatDateOnly`) every ingredient across that week's planned
* recipes, summed. Unlike {@link getPlanningForWeek}, never resolves to
* `null`: no household or nothing planned that week both come back as a
* normal list with an empty `items` array.
*/
public getShoppingListForWeek(date: string): Promise<ShoppingListView> {
return this._request(`/shopping-list?date=${date}`);
}
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */ /** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
public getDiets(): Promise<DietView[]> { public getDiets(): Promise<DietView[]> {
return this._request("/reference/diets"); return this._request("/reference/diets");
@ -179,6 +197,16 @@ export class ApiClient {
return this._request("/reference/units"); return this._request("/reference/units");
} }
/** Reference list of detected cooking techniques — static, non-administrable (`TechStepCorrectionPopover`'s technique picker). Public — no session required. */
public getTechSteps(): Promise<TechStepView[]> {
return this._request("/reference/tech-steps");
}
/** Reference list of cooking utensils — static, non-administrable (`TechStepCorrectionPopover`'s utensil picker, once a technique is selected). Public — no session required. */
public getUtensils(): Promise<UtensilView[]> {
return this._request("/reference/utensils");
}
/** Reference list of implemented recipe sources (onboarding wizard's source step, `/parametres/foyer`) — empty until a concrete source is registered. Public — no session required. */ /** Reference list of implemented recipe sources (onboarding wizard's source step, `/parametres/foyer`) — empty until a concrete source is registered. Public — no session required. */
public getSources(): Promise<SourceView[]> { public getSources(): Promise<SourceView[]> {
return this._request("/reference/sources"); return this._request("/reference/sources");
@ -267,6 +295,26 @@ export class ApiClient {
return this._request(`/recipes/${id}/favorite`, { method: "DELETE" }); return this._request(`/recipes/${id}/favorite`, { method: "DELETE" });
} }
/** Submits a correction to one of `stepId`'s detected techniques — see `SubmitTechStepCorrectionInput`'s doc comment (`packages/shared`) for what `previousTechStepId`/`correctedTechStepId` each mean. Open to any viewer who can see the recipe, not just its author. The response's `techSteps` is the step's fresh, immediately up-to-date technique sequence — see `SubmitTechStepCorrectionResult`'s doc comment. */
public submitTechStepCorrection(
recipeId: number,
stepId: number,
input: SubmitTechStepCorrectionInput,
): Promise<SubmitTechStepCorrectionResult> {
return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** Every correction submitted so far for `stepId`, most recent first. */
public getTechStepCorrections(
recipeId: number,
stepId: number,
): Promise<StepTechStepCorrectionView[]> {
return this._request(`/recipes/${recipeId}/steps/${stepId}/corrections`);
}
/** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */ /** Fetches the current user's household (with its member list), or `null` if they don't have one yet. */
public getCurrentHouse(): Promise<HouseView | null> { public getCurrentHouse(): Promise<HouseView | null> {
return this._request("/house/current"); return this._request("/house/current");

View file

@ -1,17 +0,0 @@
// =============================================================================
// Styles for ComingSoonPage shared by every stub section page.
// =============================================================================
// Centered, not pinned to `.app-content`'s left edge — same reasoning as
// `.settings-page` (settings-pages.scss): on a wide desktop viewport a
// left-aligned `max-width` here just left a lopsided gap down the right
// side instead of framing the placeholder copy.
.coming-soon-page {
max-width: 40rem;
margin: 0 auto;
p {
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
}

View file

@ -1,26 +0,0 @@
import "./ComingSoonPage.scss";
interface ComingSoonPageProps {
title: string;
description: string;
}
/**
* Placeholder rendered by a section that has a route/sidebar entry but no
* real feature behind it yet today only `pages/shopping-list/ShoppingListPage.tsx`
* (`Recettes`/`Foyer & profil` both grew real backends since this was
* written, see `pages/recipes/`/`pages/settings/`). Kept as a shared,
* reusable component (`components/ui/`, not itself a routed page) rather
* than inlined into that one page, so a future stub section doesn't need to
* hand-roll the same markup the page that needs it still gets its own
* file (and its own copy, via i18n), just wrapping this instead of
* rewriting it.
*/
export function ComingSoonPage({ title, description }: ComingSoonPageProps) {
return (
<div className="coming-soon-page">
<h1>{title}</h1>
<p>{description}</p>
</div>
);
}

View file

@ -0,0 +1,174 @@
import {
addWeeks,
buildCalendarMonth,
DateTime,
getWeekStart,
toDateOnly,
} from "@batch-cooking/date-tools";
import { WEEK_DAYS } from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import "./week-navigator.scss";
/** "17 au 23 août 2026" — collapses the month/year to just the end date when both ends of the week share it, spells it out on both ends otherwise (e.g. a week straddling two months). */
function formatWeekRange(weekStart: DateTime): string {
const weekEnd = weekStart.plus({ days: 6 });
const sameMonth = weekStart.hasSame(weekEnd, "month");
const startLabel = weekStart.toLocaleString(
sameMonth ? { day: "numeric" } : { day: "numeric", month: "long" },
{ locale: "fr" },
);
const endLabel = weekEnd.toLocaleString(
{ day: "numeric", month: "long", year: "numeric" },
{ locale: "fr" },
);
return `${startLabel} au ${endLabel}`;
}
/**
* Arrows + clickable label opening {@link CalendarPopover} week-selection
* UI shared by any page organized around "one week at a time" (originally
* `PlanningPage`'s own grid, now also `ShoppingListPage` both just need a
* `weekStart` in/out, neither cares how the other renders its own content
* for that week). Copy comes from `common.weekNav.*`/`common.calendar.*`/
* `common.days.*` rather than `planning.*` generic enough ("Semaine
* précédente", day names) to not read as planning-specific from a page that
* isn't the planning grid.
*/
export function WeekNavigator({
weekStart,
onChangeWeek,
}: {
weekStart: DateTime;
onChangeWeek: (weekStart: DateTime) => void;
}) {
const { t } = useTranslation();
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const isThisWeek = weekStart.hasSame(getWeekStart(DateTime.utc()), "day");
return (
<div className="week-nav">
<button
type="button"
className="week-nav__arrow"
title={t("common.weekNav.prevWeek")}
onClick={() => onChangeWeek(addWeeks(weekStart, -1))}
>
</button>
<button
type="button"
className="week-nav__label"
onClick={() => setIsCalendarOpen((open) => !open)}
>
📅 {t("common.weekNav.label", { range: formatWeekRange(weekStart) })}
{isThisWeek && <span className="today-badge">{t("common.weekNav.thisWeek")}</span>}
</button>
<button
type="button"
className="week-nav__arrow"
title={t("common.weekNav.nextWeek")}
onClick={() => onChangeWeek(addWeeks(weekStart, 1))}
>
</button>
{isCalendarOpen && (
<CalendarPopover
selectedWeekStart={weekStart}
onSelectDay={(day) => {
onChangeWeek(getWeekStart(day));
setIsCalendarOpen(false);
}}
onClose={() => setIsCalendarOpen(false)}
/>
)}
</div>
);
}
/** Month calendar letting the visitor jump to any week at once — selecting a day selects its whole (Monday-first) week. Closes itself on an outside click. */
function CalendarPopover({
selectedWeekStart,
onSelectDay,
onClose,
}: {
selectedWeekStart: DateTime;
onSelectDay: (day: DateTime) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
// Its own state: browsing to a different month to pick a week there
// shouldn't jump back every render — only re-anchors when the popover is
// first opened (`selectedWeekStart` at that point), not while it's open.
const [visibleMonth, setVisibleMonth] = useState(() => selectedWeekStart.startOf("month"));
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const today = toDateOnly(DateTime.utc());
const selectedWeekEnd = selectedWeekStart.plus({ days: 6 });
const weeks = buildCalendarMonth(visibleMonth);
return (
<div className="calendar-popover" ref={popoverRef}>
<div className="calendar-popover__header">
<button
type="button"
title={t("common.calendar.prevMonth")}
onClick={() => setVisibleMonth((month) => month.minus({ months: 1 }))}
>
</button>
<span>
{visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })}
</span>
<button
type="button"
title={t("common.calendar.nextMonth")}
onClick={() => setVisibleMonth((month) => month.plus({ months: 1 }))}
>
</button>
</div>
<div className="calendar-grid">
{WEEK_DAYS.map((weekDay) => (
<span key={weekDay} className="calendar-grid__weekday">
{t(`common.days.${weekDay}`).charAt(0)}
</span>
))}
{weeks.flat().map((day) => {
const classNames = ["calendar-grid__day"];
if (!day.hasSame(visibleMonth, "month")) classNames.push("calendar-grid__day--muted");
if (day >= selectedWeekStart && day <= selectedWeekEnd) {
classNames.push("calendar-grid__day--in-selected-week");
}
if (day.hasSame(today, "day")) classNames.push("calendar-grid__day--today");
return (
<button
key={day.toISO()}
type="button"
className={classNames.join(" ")}
onClick={() => onSelectDay(day)}
>
{day.day}
</button>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,143 @@
// =============================================================================
// Styles for WeekNavigator.tsx (arrows + label + calendar popover)
// colocated next to the component since nothing else uses these classes.
// Extracted from planning-page.scss once ShoppingListPage started reusing
// the component same design tokens, no light/dark duplication needed
// (every `var(--color-*)` below already resolves per-theme globally, see
// styles/_theme.scss).
// =============================================================================
.week-nav {
position: relative;
display: flex;
align-items: center;
gap: var(--space-xs);
&__arrow {
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--font-size-md);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
}
&__label {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 0.45rem var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font-weight: 600;
font-size: var(--font-size-sm);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
}
}
.today-badge {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
padding: 0.1rem 0.4rem;
border-radius: var(--radius-pill);
}
// --- Calendar popover -------------------------------------------------------
.calendar-popover {
position: absolute;
top: calc(100% + var(--space-xs));
right: 0;
z-index: 10;
width: 18rem;
padding: var(--space-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-sm);
font-weight: 700;
font-size: var(--font-size-sm);
text-transform: capitalize;
button {
width: 1.6rem;
height: 1.6rem;
border: none;
background: none;
cursor: pointer;
font-size: var(--font-size-base);
color: var(--color-text-muted);
border-radius: var(--radius-base);
&:hover {
background: var(--color-surface-alt);
}
}
}
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
&__weekday {
text-align: center;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-weight: 600;
padding-bottom: var(--space-xs);
}
&__day {
aspect-ratio: 1;
display: grid;
place-items: center;
font-size: var(--font-size-sm);
border-radius: var(--radius-base);
cursor: pointer;
color: var(--color-text);
border: none;
background: none;
font: inherit;
&:hover {
background: var(--color-surface-alt);
}
&--muted {
color: var(--color-border);
}
&--in-selected-week {
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border-radius: 0;
}
&--today {
box-shadow: inset 0 0 0 2px var(--color-primary);
font-weight: 700;
}
}
}

View file

@ -205,11 +205,34 @@ export function RecipeDetailPanel({
<section className="recipe-detail-panel__section"> <section className="recipe-detail-panel__section">
<h3>{t("recipes.stepsTitle")}</h3> <h3>{t("recipes.stepsTitle")}</h3>
{/* Discoverability hint for the highlight/correction feature below
nothing about the steps list itself otherwise signals that a
highlighted technique or a plain-text selection is interactive.
Tied to `showActions`, same reasoning as `StepDescription`'s own
`editable` prop right below. */}
{showActions && (
<p className="recipe-detail-panel__tech-step-hint">
{t("recipes.techStepCorrection.discoverabilityHint")}
</p>
)}
<ol className="recipe-detail-panel__steps"> <ol className="recipe-detail-panel__steps">
{recipe.steps.map((step) => ( {recipe.steps.map((step) => (
<li key={step.id}> <li key={step.id}>
{step.picture && <img src={step.picture} alt="" />} {step.picture && <img src={step.picture} alt="" />}
<StepDescription description={step.description} techSteps={step.techSteps} /> {/* Tied to `showActions` (not unconditionally on): that flag
already distinguishes a full recipe view from a lightweight
preview (`RecipePickerDialog`'s browsing step, `showActions={false}`)
offering technique corrections in a quick "pick a recipe
for planning" preview would be more distracting than
useful there, even though the API itself allows it for any
viewer who can see the recipe. */}
<StepDescription
description={step.description}
techSteps={step.techSteps}
editable={showActions}
recipeId={recipe.id}
stepId={step.id}
/>
</li> </li>
))} ))}
</ol> </ol>

View file

@ -372,20 +372,75 @@
gap: var(--space-sm); gap: var(--space-sm);
} }
.source-items-load-more { // Browsing a source scrolls infinitely (SourceItemTable's own sentinel row
flex-shrink: 0; // triggers RecipeSourcesPanel's handleLoadMore) — this is only the inline
align-self: center; // retry action shown alongside recipes.sources.loadMoreError when a page
padding: 0.4rem var(--space-lg); // fetch actually fails, not a persistent "load more" control.
font-family: var(--font-body); .source-items-retry {
font-size: var(--font-size-sm); padding: 0;
font-family: inherit;
font-size: inherit;
font-weight: 600;
color: var(--color-primary); color: var(--color-primary);
background: none; background: none;
border: 1px solid var(--color-border); border: none;
border-radius: var(--radius-pill); text-decoration: underline;
cursor: pointer; cursor: pointer;
}
// Pulsing placeholder rows `SourceItemTable` appends below the real items
// while `RecipeSourcesPanel`'s "load more" fetch is in flight — with that
// panel prefetching the next page ahead of time (as soon as the sentinel
// row scrolls near view), this is usually a very brief flash rather than
// an actual wait, but it keeps the list
// filling in instead of looking like nothing happened either way.
@keyframes source-item-skeleton-pulse {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 0.9;
}
}
// Invisible marker row `SourceItemTable`'s `IntersectionObserver` watches
// to trigger infinite scroll no padding/border of its own, unlike a real
// row, so it doesn't show up as a stray empty stripe at the bottom of the
// list.
.source-item-table__sentinel td {
padding: 0;
border: none;
}
.source-item-table__row--skeleton {
cursor: default;
pointer-events: none;
&:hover { &:hover {
border-color: var(--color-primary); background: none;
}
}
.source-item-table__photo--skeleton,
.source-item-table__skeleton-bar {
background: var(--color-border);
animation: source-item-skeleton-pulse 1.4s ease-in-out infinite;
}
.source-item-table__skeleton-bar {
display: block;
width: 60%;
height: 0.9rem;
border-radius: var(--radius-pill);
}
@media (prefers-reduced-motion: reduce) {
.source-item-table__photo--skeleton,
.source-item-table__skeleton-bar {
animation: none;
opacity: 0.6;
} }
} }
@ -627,12 +682,273 @@
} }
} }
// A `"manual"`-sourced match (a viewer's correction, applied immediately —
// see `StepTechStepView.source`) same shape as `.step-tech-step`, but in
// `--color-tag` (Turmeric) instead of `--color-primary` (Basil), so the two
// origins are distinguishable at a glance, not just via the tooltip text.
.step-tech-step--manual {
background: color-mix(in srgb, var(--color-tag) 18%, transparent);
text-decoration-color: var(--color-tag);
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--color-tag) 28%, transparent);
}
}
// `.step-tech-step-context` (the wider clause a `.step-tech-step` keyword // `.step-tech-step-context` (the wider clause a `.step-tech-step` keyword
// was found in) used to be highlighted here too, more subtly turned back // was found in) used to be highlighted here too, more subtly turned back
// off (see `StepDescription.tsx`'s doc comment): the backend still // off (see `StepDescription.tsx`'s doc comment): the backend still
// computes and persists `contextStart`/`contextEnd`, this file just no // computes and persists `contextStart`/`contextEnd`, this file just no
// longer gives that class any styling to render with. // longer gives that class any styling to render with.
// Discoverability hint above the steps list (RecipeDetailPanel.tsx)
// muted so it reads as a small aside, not competing with the steps
// themselves for attention.
.recipe-detail-panel__tech-step-hint {
margin: 0 0 var(--space-sm);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// --- Tech-step correction (StepDescription.tsx editable mode) ---------------
// Deliberately *not* `position: absolute` (unlike `.calendar-popover`) see
// `TechStepCorrectionPopover.tsx`'s doc comment for why this renders inline
// in the document flow right below the step's own description instead of
// floating anchored at the selection.
.tech-step-correction-popover {
margin-top: var(--space-xs);
padding: var(--space-md);
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
&__selection {
font-weight: 600;
margin: 0 0 var(--space-sm);
}
// Technique picker + Ingrédients/Ustensiles render together as one
// screen now (see `TechStepCorrectionPopover.tsx`'s own doc comment) —
// this section just needs its own small header row, the actual picker
// is `.catalog-search-picker` (below), reused as-is from the ingredient/
// utensil sub-flows.
&__technique-section {
h4 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
&__technique-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-sm);
}
&__remove {
padding: 0.2rem 0.5rem;
font-size: var(--font-size-sm);
color: var(--color-error);
background: none;
border: 1px solid var(--color-error);
border-radius: var(--radius-pill);
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&__cancel {
background: none;
border: none;
color: var(--color-text-muted);
text-decoration: underline;
cursor: pointer;
padding: 0;
font-size: var(--font-size-sm);
}
// Shown in place of the technique list/metadata sections while
// `StepDescription` is waiting on a second text selection (see
// `TechStepCorrectionPopover.tsx`'s own doc comment) — same styling
// intent as `.recipe-detail-panel__tech-step-hint`, a small muted aside.
&__hint {
margin: 0 0 var(--space-sm);
color: var(--color-text-muted);
font-style: italic;
}
&__span-picker {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
&__quantity-line {
display: flex;
align-items: center;
gap: var(--space-xs);
input[type="number"] {
width: 5rem;
}
}
&__confirm {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
// "Aucune technique sélectionnée."/"Technique retenue : X" no button
// here anymore (re-picking happens directly through the search picker
// right below, see `TechStepCorrectionPopover.tsx`'s doc comment on the
// merged editor), just a small status line.
&__chosen-technique {
margin: 0 0 var(--space-xs);
font-weight: 600;
}
&__metadata-section {
margin-top: var(--space-sm);
h4 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// "+ Ajouter…" button deliberately a plain text-link style, not
// another pill button (`.catalog-search-picker__list button`) this
// is a secondary action inside an already-open popover, not a
// top-level choice competing with the chips above it.
> button {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
padding: 0;
font-size: var(--font-size-sm);
}
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
list-style: none;
margin: 0 0 var(--space-xs);
padding: 0;
}
&__chip {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 0.3rem 0.6rem;
font-size: var(--font-size-sm);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
button {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 0;
line-height: 1;
&:hover:not(:disabled) {
color: var(--color-error);
}
}
}
&__confirm-button {
align-self: flex-start;
padding: 0.4rem 1rem;
font-size: var(--font-size-sm);
color: var(--color-surface);
background: var(--color-primary);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
// Reused by both the ingredient and utensil "attach to this correction"
// sub-flows (`TechStepCorrectionPopover.tsx`) deliberately lighter than
// `.ingredient-picker` (no category/subcategory grid, no allergen/diet
// toggles), sized for a small popover rather than a full recipe form.
.catalog-search-picker {
display: flex;
flex-direction: column;
gap: var(--space-xs);
&__input {
width: 100%;
}
&__empty {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
&__list {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
list-style: none;
margin: 0;
padding: 0;
// Raised from the original 8rem this component is now also the
// technique picker (~74 entries, see this file's own doc comment),
// where 8rem left only a couple of rows visible before scrolling.
max-height: 14rem;
overflow-y: auto;
button {
padding: 0.3rem 0.6rem;
font-size: var(--font-size-sm);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
cursor: pointer;
&:hover {
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border-color: var(--color-primary);
}
// The technique picker's current pick (`selectedId` prop) — stays
// visually marked even while filtered/scrolled past, so re-opening
// this popover's picker doesn't read as "nothing chosen yet" when
// something already is. Unused by the ingredient/utensil sub-flows
// (they never pass `selectedId` each pick there just appends a
// fresh mention, nothing to mark as "current").
&.catalog-search-picker__item--selected {
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
border-color: var(--color-primary);
font-weight: 600;
}
}
}
}
// --- Favorite star toggle (detail panel header) ----------------------------- // --- Favorite star toggle (detail panel header) -----------------------------
.favorite-star-button { .favorite-star-button {
position: absolute; position: absolute;

View file

@ -1,5 +1,5 @@
import type { BrowsableSourceItemView, RecipeView } from "@batch-cooking/shared"; import type { BrowsableSourceItemView, RecipeView } from "@batch-cooking/shared";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { apiClient } from "../../../api/client"; import { apiClient } from "../../../api/client";
import { RecipeDetailPanel, type RecipeDetailState } from "../RecipeDetailPanel"; import { RecipeDetailPanel, type RecipeDetailState } from "../RecipeDetailPanel";
@ -9,6 +9,15 @@ import "../recipes.scss";
/** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */ /** Debounce for the search field — same idea/value as `RecipesPage`'s own search. */
const SEARCH_DEBOUNCE_MS = 300; const SEARCH_DEBOUNCE_MS = 300;
/** How many pulsing skeleton rows `handleLoadMore` shows while its fetch is in flight — see `loadMoreStatus`. Not tied to any source's real page size (that varies per source, and isn't known client-side); just enough to visibly fill the gap below the list without over-promising. */
const LOAD_MORE_PLACEHOLDER_COUNT = 4;
/** One page of `sourceKey`'s browsable catalog, as returned by `apiClient.browseSource`. */
interface BrowsePage {
items: BrowsableSourceItemView[];
nextCursor: string | null;
}
/** One item's identity within a source's browsable catalog — `sourceKey` + `externalId` together, since `externalId` alone is only unique per source. */ /** One item's identity within a source's browsable catalog — `sourceKey` + `externalId` together, since `externalId` alone is only unique per source. */
export interface SourceItemSelection { export interface SourceItemSelection {
sourceKey: string; sourceKey: string;
@ -76,6 +85,30 @@ export function RecipeSourcesPanel({
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState("");
const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" }); const [browseState, setBrowseState] = useState<BrowseState>({ status: "loading" });
const [loadMoreStatus, setLoadMoreStatus] = useState<"idle" | "loading" | "error">("idle");
// The *next* page, fetched ahead of time as soon as the current one is on
// screen (see the effect below) — a ref, not state, since it's an
// implementation detail `handleLoadMore` consumes, never itself rendered.
// Keyed on exactly what makes a prefetch valid to reuse (source/query/
// cursor all matching) rather than just "is something in flight", so a
// stale prefetch from before a search/source change is never mistaken for
// the page that's actually needed next.
const nextPagePrefetchRef = useRef<{
sourceKey: string;
query: string;
cursor: string;
promise: Promise<BrowsePage>;
} | null>(null);
// Re-entrancy guard for `handleLoadMore` — infinite scroll (unlike a
// button `onClick`) can call it again before the previous call has
// settled (e.g. the sentinel row is still intersecting when the observer
// re-evaluates after a layout shift). A ref, not `loadMoreStatus`: that
// state only exists to drive what's rendered and is read from React's
// closure at call time, which would still read the *previous* render's
// (stale) value inside a handler fired synchronously off a fresh
// browser event — this needs to be checked/set immediately and
// synchronously, which only a ref does correctly here.
const isLoadingMoreRef = useRef(false);
const [selectedExternalId, setSelectedExternalId] = useState<string | null>( const [selectedExternalId, setSelectedExternalId] = useState<string | null>(
initialSelection?.externalId ?? null, initialSelection?.externalId ?? null,
); );
@ -127,6 +160,11 @@ export function RecipeSourcesPanel({
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setBrowseState({ status: "loading" }); setBrowseState({ status: "loading" });
// A fresh search/source is a fresh list — any in-flight "load more" or
// stale prefetch from the *previous* one no longer applies to anything.
setLoadMoreStatus("idle");
nextPagePrefetchRef.current = null;
isLoadingMoreRef.current = false;
apiClient apiClient
.browseSource(sourceKey, { query: debouncedSearch.trim() || undefined }) .browseSource(sourceKey, { query: debouncedSearch.trim() || undefined })
@ -142,21 +180,78 @@ export function RecipeSourcesPanel({
}; };
}, [sourceKey, debouncedSearch]); }, [sourceKey, debouncedSearch]);
// Prefetches the page after the one currently on screen, so scrolling
// near the bottom (SourceItemTable's sentinel row, which calls
// handleLoadMore) usually just swaps in data that's already arrived
// instead of starting a fresh round-trip right when someone's waiting on
// it — `handleLoadMore` below reuses this when it matches. Re-runs on every
// `browseState` change, so a load-more that appends a new page and a new
// `nextCursor` immediately kicks off prefetching the page *after* that
// one too, keeping the panel permanently one page ahead of what's shown.
useEffect(() => {
if (browseState.status !== "loaded" || !browseState.nextCursor) return;
const cursor = browseState.nextCursor;
const query = debouncedSearch.trim();
const already = nextPagePrefetchRef.current;
if (
already &&
already.sourceKey === sourceKey &&
already.query === query &&
already.cursor === cursor
) {
return; // already prefetching/prefetched exactly this page
}
const promise = apiClient.browseSource(sourceKey, { query: query || undefined, cursor });
nextPagePrefetchRef.current = { sourceKey, query, cursor, promise };
// A failed prefetch is swallowed here on purpose — nobody's actually
// waiting on it yet. If `handleLoadMore` later reuses this same promise
// it awaits/catches the rejection itself at that point; if it's never
// reused (the prefetch just goes stale), this `.catch()` only exists to
// keep the rejection from surfacing as an unhandled one.
promise.catch(() => {});
}, [browseState, sourceKey, debouncedSearch]);
function handleLoadMore() { function handleLoadMore() {
if (browseState.status !== "loaded" || !browseState.nextCursor) { if (browseState.status !== "loaded" || !browseState.nextCursor) {
return; return;
} }
if (isLoadingMoreRef.current) {
return; // already fetching this exact next page — see the ref's own doc comment
}
isLoadingMoreRef.current = true;
const cursor = browseState.nextCursor; const cursor = browseState.nextCursor;
apiClient const query = debouncedSearch.trim();
.browseSource(sourceKey, { query: debouncedSearch.trim() || undefined, cursor }) setLoadMoreStatus("loading");
const prefetch = nextPagePrefetchRef.current;
const request =
prefetch &&
prefetch.sourceKey === sourceKey &&
prefetch.query === query &&
prefetch.cursor === cursor
? prefetch.promise
: apiClient.browseSource(sourceKey, { query: query || undefined, cursor });
request
.then(({ items, nextCursor }) => { .then(({ items, nextCursor }) => {
nextPagePrefetchRef.current = null;
isLoadingMoreRef.current = false;
setBrowseState((prev) => setBrowseState((prev) =>
prev.status === "loaded" prev.status === "loaded"
? { status: "loaded", items: [...prev.items, ...items], nextCursor } ? { status: "loaded", items: [...prev.items, ...items], nextCursor }
: prev, : prev,
); );
setLoadMoreStatus("idle");
}) })
.catch(() => setBrowseState({ status: "error" })); .catch(() => {
// Also clears a failed *prefetch*, not just a failed manual retry —
// otherwise a rejected promise would sit in the ref forever, and
// "Réessayer" would just keep reusing (and re-rejecting on) that
// same dead promise instead of ever making a fresh request.
nextPagePrefetchRef.current = null;
isLoadingMoreRef.current = false;
setLoadMoreStatus("error");
});
} }
function handleSelectItem(item: BrowsableSourceItemView) { function handleSelectItem(item: BrowsableSourceItemView) {
@ -217,11 +312,17 @@ export function RecipeSourcesPanel({
items={browseState.items} items={browseState.items}
selectedExternalId={selectedExternalId} selectedExternalId={selectedExternalId}
onSelect={handleSelectItem} onSelect={handleSelectItem}
placeholderCount={loadMoreStatus === "loading" ? LOAD_MORE_PLACEHOLDER_COUNT : 0}
hasMore={browseState.nextCursor !== null}
onLoadMore={handleLoadMore}
/> />
{browseState.nextCursor && ( {loadMoreStatus === "error" && (
<button type="button" className="source-items-load-more" onClick={handleLoadMore}> <p className="recipes-page__status recipes-page__status--error">
{t("recipes.sources.loadMore")} {t("recipes.sources.loadMoreError")}{" "}
<button type="button" className="source-items-retry" onClick={handleLoadMore}>
{t("recipes.sources.retry")}
</button> </button>
</p>
)} )}
</div> </div>
)} )}

View file

@ -1,4 +1,5 @@
import type { BrowsableSourceItemView } from "@batch-cooking/shared"; import type { BrowsableSourceItemView } from "@batch-cooking/shared";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import "../recipes.scss"; import "../recipes.scss";
@ -8,20 +9,66 @@ import "../recipes.scss";
* an "already imported" badge in place of allergen/regime columns (a * an "already imported" badge in place of allergen/regime columns (a
* source item has neither, it's not resolved against our catalogs until * source item has neither, it's not resolved against our catalogs until
* previewed). * previewed).
*
* Infinite scroll, not a "voir plus" button: a zero-content sentinel row
* (`source-item-table__sentinel`) sits right after `items`, watched by an
* `IntersectionObserver` scoped to this table's own scrolling container
* (`.recipe-table-wrap`, not the page) scrolling it into view calls
* `onLoadMore`, the same callback a button's `onClick` would have. Kept
* entirely inside this component rather than exposed as a prop callback
* signature change on every call site: `RecipeSourcesPanel` doesn't need to
* know *how* "load more" gets triggered, only that it does.
*/ */
export function SourceItemTable({ export function SourceItemTable({
items, items,
selectedExternalId, selectedExternalId,
onSelect, onSelect,
placeholderCount = 0,
hasMore = false,
onLoadMore,
}: { }: {
items: BrowsableSourceItemView[]; items: BrowsableSourceItemView[];
selectedExternalId: string | null; selectedExternalId: string | null;
onSelect: (item: BrowsableSourceItemView) => void; onSelect: (item: BrowsableSourceItemView) => void;
/**
* Extra pulsing skeleton rows appended after `items` `RecipeSourcesPanel`
* sets this while a "load more" fetch is in flight, so the list fills in
* right away instead of looking like nothing happened. Purely decorative:
* never clickable/focusable, unlike a real row.
*/
placeholderCount?: number;
/** Whether a further page exists — renders the sentinel row (and therefore observes it) only when true; the observer would otherwise have nothing meaningful to trigger once the catalog is exhausted. */
hasMore?: boolean;
/** Called once when the sentinel row scrolls into view — `RecipeSourcesPanel`'s own re-entrancy guard (not this component) is what keeps a still-visible sentinel from firing this repeatedly while a fetch is already in flight. */
onLoadMore?: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const sentinelRef = useRef<HTMLTableRowElement | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: `onLoadMore` is deliberately excluded — RecipeSourcesPanel passes a fresh function identity on every render, and re-subscribing the observer on every single render (rather than only when hasMore actually flips) would be wasteful busywork for no behavioral difference.
useEffect(() => {
const root = scrollContainerRef.current;
const sentinel = sentinelRef.current;
if (!hasMore || !onLoadMore || !root || !sentinel) return;
// `rootMargin` starts loading the next page slightly before the
// sentinel actually reaches the visible edge — combined with
// RecipeSourcesPanel's own prefetch-ahead-of-time, the goal is that a
// page finishes arriving before anyone actually scrolls far enough to
// need it, not just "as soon as" they do.
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) onLoadMore();
},
{ root, rootMargin: "200px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore]);
return ( return (
<div className="recipe-table-wrap"> <div className="recipe-table-wrap" ref={scrollContainerRef}>
<table className="recipe-table"> <table className="recipe-table">
<thead> <thead>
<tr> <tr>
@ -60,6 +107,23 @@ export function SourceItemTable({
</td> </td>
</tr> </tr>
))} ))}
{hasMore && (
<tr ref={sentinelRef} className="source-item-table__sentinel">
<td colSpan={3} />
</tr>
)}
{Array.from({ length: placeholderCount }, (_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: a fixed-length run of interchangeable, content-less placeholders — there's no stable identity to key on, and the count never reorders.
<tr key={`skeleton-${index}`} className="source-item-table__row--skeleton">
<td>
<span className="recipe-table__photo source-item-table__photo--skeleton" />
</td>
<td>
<span className="source-item-table__skeleton-bar" />
</td>
<td />
</tr>
))}
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -0,0 +1,71 @@
import { useState } from "react";
/**
* Small search-and-pick list a lighter alternative to `IngredientPicker.tsx`
* (category/subcategory grid + allergen/diet toggles) for a context that
* doesn't have room for that: `TechStepCorrectionPopover.tsx`'s ingredient/
* utensil/**technique** pickers, all embedded in a small popover rather than
* a full recipe form. Reused for all three an ingredient, a utensil, and a
* technique are all "search a reference list by translated label, pick one"
* from this component's point of view, the only difference is which
* `items`/labels the caller passes in. The technique catalog in particular
* (~74 entries) is exactly the case a plain unfiltered list stops being
* readable at the original motivation for adding search here at all.
*
* Deliberately just `{ id, label }` in, `id` out no `IngredientView`/
* `UtensilView`/`TechStepView` dependency here, so this stays reusable for
* any future "search this small reference catalog" need without growing a
* new prop per catalog shape.
*/
export function CatalogSearchPicker({
items,
selectedId,
onSelect,
placeholder,
emptyLabel,
}: {
items: { id: number; label: string }[];
/** The currently-picked item, if any — marked with a distinct modifier class so it stays visible at a glance while browsing/filtering a longer list (e.g. `TechStepCorrectionPopover`'s ~74-entry technique catalog), not just implied by whatever's selected elsewhere on screen. Omit for a picker with no notion of a "current" pick (the ingredient/utensil span sub-flows — each `onSelect` there just appends a brand-new mention, nothing to mark as already chosen). */
selectedId?: number;
onSelect: (id: number) => void;
placeholder: string;
emptyLabel: string;
}) {
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const visible =
normalizedQuery.length === 0
? items
: items.filter((item) => item.label.toLowerCase().includes(normalizedQuery));
return (
<div className="catalog-search-picker">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="catalog-search-picker__input"
/>
{visible.length === 0 ? (
<p className="catalog-search-picker__empty">{emptyLabel}</p>
) : (
<ul className="catalog-search-picker__list">
{visible.map((item) => (
<li key={item.id}>
<button
type="button"
className={
item.id === selectedId ? "catalog-search-picker__item--selected" : undefined
}
onClick={() => onSelect(item.id)}
>
{item.label}
</button>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -1,8 +1,10 @@
import type { StepTechStepView } from "@batch-cooking/shared"; import type { StepTechStepView, SubmitTechStepCorrectionResult } from "@batch-cooking/shared";
import { Fragment } from "react"; import { Fragment, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Tooltip } from "../../../components/ui/Tooltip"; import { Tooltip } from "../../../components/ui/Tooltip";
import { splitDescriptionByTechSteps } from "./highlight-tech-steps"; import { splitDescriptionByTechSteps } from "./highlight-tech-steps";
import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
/** /**
* A recipe step's description, with every detected technique's exact * A recipe step's description, with every detected technique's exact
@ -23,48 +25,222 @@ import { splitDescriptionByTechSteps } from "./highlight-tech-steps";
* *
* `techStep.key` resolves its tooltip label through `catalog.techSteps.<key>` * `techStep.key` resolves its tooltip label through `catalog.techSteps.<key>`
* i18n, the same pattern every other reference catalog (diets, units, ) * i18n, the same pattern every other reference catalog (diets, units, )
* uses for its display text. * uses for its display text. A keyword's `source` (`"auto"` the
* classifier vs `"manual"` a viewer's correction, applied immediately)
* gets its own modifier class (`.step-tech-step--manual`), a different
* color, so the two are visually distinguishable at a glance rather than
* only via the tooltip text.
*
* `editable` (off by default) additionally lets the viewer select text or
* click an existing highlight to open a {@link TechStepCorrectionPopover}
* see `use-text-selection.ts` for how a browser selection is translated
* into an absolute `[start, end)` span. When `!editable`, every segment
* renders exactly as before (no extra wrapping elements, no `data-offset`,
* no click handlers) this mode is purely additive, not a rewrite of the
* read-only rendering.
*
* Maintains its own local copy of `techSteps` (seeded from the prop, then
* replaced with whatever `POST .../corrections` returns on a successful
* submit see `SubmitTechStepCorrectionResult`'s doc comment,
* `packages/shared`) so a correction's effect (a new/relabeled/removed
* highlight) appears immediately, without needing the parent to re-fetch
* the whole recipe. Resynced whenever the `techSteps` prop itself changes
* (e.g. the parent reloaded the recipe for an unrelated reason) so this
* never keeps showing stale local state past that.
*/ */
export function StepDescription({ export function StepDescription({
description, description,
techSteps, techSteps,
editable = false,
recipeId,
stepId,
}: { }: {
description: string; description: string;
techSteps: StepTechStepView[]; techSteps: StepTechStepView[];
/** Requires `recipeId`/`stepId` when `true` — omit (or leave `false`) for a read-only view with nothing real to correct against yet (e.g. `RecipeDetailPanel`'s `"loaded-draft"` unsaved-preview branch). */
editable?: boolean;
recipeId?: number;
stepId?: number;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const segments = splitDescriptionByTechSteps(description, techSteps); const [liveTechSteps, setLiveTechSteps] = useState(techSteps);
useEffect(() => setLiveTechSteps(techSteps), [techSteps]);
const segments = splitDescriptionByTechSteps(description, liveTechSteps);
const containerRef = useRef<HTMLParagraphElement>(null);
const { getSelectionRange } = useTextSelection(containerRef);
const [activeCorrection, setActiveCorrection] = useState<{
range: TextSelectionRange;
selectedText: string;
previousTechStepId: number | null;
} | null>(null);
// Routes the *next* text selection to the open `TechStepCorrectionPopover`
// (as an ingredient/utensil mention span) instead of opening a brand-new
// correction — set when that popover calls `onRequestSpan`, cleared once
// `handleMouseUp` resolves the selection below. See
// `TechStepCorrectionPopover.tsx`'s own doc comment for why this can live
// entirely alongside the still-visible, still-selectable description
// rather than needing the popover itself to move/hide.
const [pendingSpanRequest, setPendingSpanRequest] = useState<"ingredient" | "utensil" | null>(
null,
);
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
nonce: number;
kind: "ingredient" | "utensil";
range: TextSelectionRange;
text: string;
} | null>(null);
const nextMetadataSpanNonce = useRef(0);
function closeActiveCorrection() {
setActiveCorrection(null);
setPendingSpanRequest(null);
setResolvedMetadataSpan(null);
}
function handleMouseUp() {
if (!editable) return;
const range = getSelectionRange();
if (!range) return;
if (pendingSpanRequest !== null) {
nextMetadataSpanNonce.current += 1;
setResolvedMetadataSpan({
nonce: nextMetadataSpanNonce.current,
kind: pendingSpanRequest,
range,
text: description.slice(range.start, range.end),
});
setPendingSpanRequest(null);
return;
}
setActiveCorrection({
range,
selectedText: description.slice(range.start, range.end),
previousTechStepId: null,
});
}
function handleSubmitted(result: SubmitTechStepCorrectionResult) {
setLiveTechSteps(result.techSteps);
}
// The occurrence `activeCorrection` is currently open for, matched by its
// exact `[start, end)` (not just `techStep.id` — the same technique can
// legitimately occur more than once in one description) — whatever
// ingredients/utensils it already carries seed
// `TechStepCorrectionPopover`'s own pending lists. `undefined` (not an
// empty array) for a brand-new selection, same as "nothing to look up
// yet".
const activeStepTechStep = activeCorrection
? liveTechSteps.find(
(techStep) =>
techStep.start === activeCorrection.range.start &&
techStep.end === activeCorrection.range.end,
)
: undefined;
// Tracks each segment's own absolute start offset into `description` as
// the map below walks them in order — segments are contiguous and cover
// the whole description (see `splitDescriptionByTechSteps`'s doc
// comment), so a running total is exact, no re-derivation needed.
let offset = 0;
return ( return (
<p> <>
<p ref={containerRef} onMouseUp={handleMouseUp}>
{segments.map((segment, index) => { {segments.map((segment, index) => {
const start = offset;
offset += segment.text.length;
// Captured now, not read as `offset` later inside a click
// handler below — `offset` keeps mutating for every subsequent
// segment this same `.map()` pass renders, so a closure
// referencing it directly would see its *final* value (the end
// of the whole description) whenever it actually fires, long
// after render — found via a real correction submitted with
// `end` far past this segment's own text.
const end = offset;
// A segment's own text/techStep don't uniquely identify it (the // A segment's own text/techStep don't uniquely identify it (the
// same word can appear twice in one description) — index is the // same word can appear twice in one description) — index is the
// only thing that does, but this list is fully regenerated from // only thing that does, but this list is fully regenerated from
// `description`/`techSteps` on every render (never reordered or // `description`/`liveTechSteps` on every render (never reordered
// spliced in place), so using it as part of the key is safe here. // or spliced in place), so using it as part of the key is safe
// here.
const key = `${index}-${segment.text}`; const key = `${index}-${segment.text}`;
if (!segment.techStep) return <Fragment key={key}>{segment.text}</Fragment>;
if (!segment.isKeyword) { if (!segment.techStep || !segment.isKeyword) {
// Context-only run — rendered as plain text, same as a segment // Context-only or plain run — rendered as plain text in
// with no technique at all (see this component's doc comment for // read-only mode, same as before this component supported
// why the wider-clause highlight was turned back off). // `editable` at all (see this component's doc comment for why
return <Fragment key={key}>{segment.text}</Fragment>; // the wider-clause highlight itself was turned back off).
} // Editable mode still wraps it in a `data-offset` span so a
// selection starting/ending in plain text resolves correctly.
if (!editable) return <Fragment key={key}>{segment.text}</Fragment>;
return ( return (
<Tooltip key={key} content={t(`catalog.techSteps.${segment.techStep.key}`)}> <span key={key} data-offset={start}>
{segment.text}
</span>
);
}
const techStep = segment.techStep;
const isManual = segment.source === "manual";
const tooltipLabel = isManual
? t("recipes.techStepCorrection.manualTooltip", {
technique: t(`catalog.techSteps.${techStep.key}`),
})
: t(`catalog.techSteps.${techStep.key}`);
return (
<Tooltip key={key} content={tooltipLabel}>
{/* A real <button>, not a <mark>, so it's natively focusable {/* A real <button>, not a <mark>, so it's natively focusable
(keyboard/screen-reader users can reach the tooltip) without (keyboard/screen-reader users can reach the tooltip) without
fighting the "non-interactive element" a11y lint a bare fighting the "non-interactive element" a11y lint a bare
tabIndex on <mark> would trip styled to read as inline tabIndex on <mark> would trip styled to read as inline
highlighted text, not as a button (see .step-tech-step). */} highlighted text, not as a button (see .step-tech-step). */}
<button type="button" className="step-tech-step"> <button
type="button"
className={isManual ? "step-tech-step step-tech-step--manual" : "step-tech-step"}
data-offset={editable ? start : undefined}
onClick={
editable
? () => {
// Clears any in-progress ingredient/utensil
// span-selection from whatever correction was open
// before — opening a *different* one has nothing
// left to resolve that selection into.
setPendingSpanRequest(null);
setResolvedMetadataSpan(null);
setActiveCorrection({
range: { start, end },
selectedText: segment.text,
previousTechStepId: techStep.id,
});
}
: undefined
}
>
{segment.text} {segment.text}
</button> </button>
</Tooltip> </Tooltip>
); );
})} })}
</p> </p>
{editable && activeCorrection && recipeId !== undefined && stepId !== undefined && (
<TechStepCorrectionPopover
recipeId={recipeId}
stepId={stepId}
range={activeCorrection.range}
selectedText={activeCorrection.selectedText}
previousTechStepId={activeCorrection.previousTechStepId}
existingIngredients={activeStepTechStep?.ingredients ?? []}
existingUtensils={activeStepTechStep?.utensils ?? []}
resolvedMetadataSpan={resolvedMetadataSpan}
onRequestSpan={setPendingSpanRequest}
onClose={closeActiveCorrection}
onSubmitted={handleSubmitted}
/>
)}
</>
); );
} }

View file

@ -0,0 +1,522 @@
import {
ErrorCode,
type IngredientView,
type StepTechStepIngredientView,
type StepTechStepUtensilView,
type SubmitTechStepCorrectionResult,
type TechStepView,
type UnitView,
type UtensilView,
} from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../../api/client";
import { errorMessageService } from "../../../services/error-message.service";
import { CatalogSearchPicker } from "./CatalogSearchPicker";
import type { TextSelectionRange } from "./use-text-selection";
/** One ingredient the viewer has attached (or is about to submit) — the trimmed-down shape `POST .../corrections`'s `ingredients[]` expects, kept separately from `StepTechStepIngredientView` since a pending one has no resolved `IngredientView`/`UnitView` to carry yet, only ids. */
interface PendingIngredient {
ingredientId: number;
quantity: number | null;
unitId: number | null;
start: number;
end: number;
}
/** Same as {@link PendingIngredient}, for a utensil (no quantity/unit — nothing to measure). */
interface PendingUtensil {
utensilId: number;
start: number;
end: number;
}
function toPendingIngredient(view: StepTechStepIngredientView): PendingIngredient {
return {
ingredientId: view.ingredient.id,
quantity: view.quantity,
unitId: view.unit?.id ?? null,
start: view.start,
end: view.end,
};
}
function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
return { utensilId: view.utensil.id, start: view.start, end: view.end };
}
/**
* Small non-modal popover letting a viewer assign a technique to a selected
* span of a step's description, or clear/relabel an existing match
* opened by `StepDescription`'s editable mode. Same `mousedown`-outside-
* close pattern as `PlanningPage`'s `CalendarPopover`, not `Dialog.tsx`'s
* native `<dialog>` this is a small, contextual pick-one-option surface,
* not a page-blocking modal.
*
* Rendered inline right below the step's own description block (see
* `StepDescription.tsx`), not floating anchored at the selection's exact
* position simpler and more robust than tracking a caret-anchored
* position across scroll/resize, at the cost of a little visual distance
* from the selected text itself. That placement matters beyond cosmetics
* here: it's *why* the "attach an ingredient/utensil" flow below can ask
* the viewer to select a second span of text without closing this popover
* first the description stays fully visible and selectable the whole
* time, nothing overlays it.
*
* **One merged editor, not a wizard**: picking a technique
* (`CatalogSearchPicker`, searchable the reference catalog is ~74
* entries, an unfiltered flat list wasn't browsable) and editing its
* Ingrédients/Ustensiles metadata render together on the same screen,
* always there's no separate "pick, then a metadata step reveals
* itself" sequence to go through, and no dead end where metadata is
* technically attachable but not visible until some other action happens
* first. A single "Valider" submits everything at once; disabled until a
* technique is actually selected (there's nothing to attach metadata to
* otherwise). **Removing** a match (`submit(null)`) stays its own
* immediate action next to the picker nothing to attach when removing.
*
* The two metadata sections are pre-seeded from `existingIngredients`/
* `existingUtensils` (whatever's already attached to this occurrence, auto-
* or manually-sourced `[]` for a brand-new technique) and editable via
* add/remove see `metadataTouched` below for why what's *displayed* here
* isn't automatically what gets *submitted*.
*/
export function TechStepCorrectionPopover({
recipeId,
stepId,
selectedText,
range,
previousTechStepId,
existingIngredients,
existingUtensils,
resolvedMetadataSpan,
onRequestSpan,
onClose,
onSubmitted,
}: {
recipeId: number;
stepId: number;
/** The selected span's own text — shown so the user confirms what they're tagging before picking a technique. */
selectedText: string;
range: TextSelectionRange;
/** Set when correcting an already-detected match (opened from clicking its highlight) rather than a fresh selection — passed through as-is on submit, and offers a "remove" option `null` doesn't. */
previousTechStepId: number | null;
/** Whatever ingredients/utensils already sit on this occurrence (both `"auto"` and `"manual"` sourced) — `[]` for a brand-new technique, nothing to pre-seed. */
existingIngredients: StepTechStepIngredientView[];
existingUtensils: StepTechStepUtensilView[];
/**
* A text span `StepDescription` just resolved on this popover's behalf,
* after a call to `onRequestSpan` below `null` until then. Identified
* by `nonce` (not by value) so this popover's own `useEffect` reliably
* fires once per fresh selection, even if the exact same span is
* selected twice in a row.
*/
resolvedMetadataSpan: {
nonce: number;
kind: "ingredient" | "utensil";
range: TextSelectionRange;
text: string;
} | null;
/** Tells `StepDescription` "the next text selection in the description is for an ingredient/utensil mention, not a new technique correction" — see this component's own doc comment. */
onRequestSpan: (kind: "ingredient" | "utensil") => void;
onClose: () => void;
onSubmitted: (result: SubmitTechStepCorrectionResult) => void;
}) {
const { t } = useTranslation();
const popoverRef = useRef<HTMLDivElement>(null);
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
// Opening this popover on an *already-detected* match (`previousTechStepId
// !== null`, i.e. the user clicked an existing highlight rather than
// selecting fresh text) starts pre-selected on that same technique, its
// name shown next to the picker right away — since the picker and the
// metadata sections render together regardless (see this component's own
// doc comment), this just saves re-picking the technique that's already
// correct before its metadata becomes editable.
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
const [catalogs, setCatalogs] = useState<{
ingredients: IngredientView[];
units: UnitView[];
utensils: UtensilView[];
} | null>(null);
const [pendingIngredients, setPendingIngredients] = useState<PendingIngredient[]>(() =>
existingIngredients.map(toPendingIngredient),
);
const [pendingUtensils, setPendingUtensils] = useState<PendingUtensil[]>(() =>
existingUtensils.map(toPendingUtensil),
);
// Flips true the moment the viewer adds/removes a pending entry — never
// from the initial seeding above. `submit()` below only includes
// `ingredients`/`utensils` in the request when this is true, so
// relabeling/confirming a technique without ever opening either section
// leaves existing metadata completely alone server-side (see
// `submitTechStepCorrectionSchema`'s own doc comment, `packages/shared`,
// for why an *omitted* field — not an empty array — is what "don't
// touch it" means over the wire).
const [metadataTouched, setMetadataTouched] = useState(false);
const [awaitingSpanFor, setAwaitingSpanFor] = useState<"ingredient" | "utensil" | null>(null);
const [activeSpan, setActiveSpan] = useState<{
kind: "ingredient" | "utensil";
range: TextSelectionRange;
text: string;
} | null>(null);
// Only meaningful while `activeSpan?.kind === "ingredient"` — the
// ingredient sub-flow is itself two steps (pick the ingredient, then its
// quantity/unit), this is where the first step's choice waits until the
// second is confirmed.
const [pickedIngredientId, setPickedIngredientId] = useState<number | null>(null);
const [spanQuantity, setSpanQuantity] = useState("");
const [spanUnitId, setSpanUnitId] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
apiClient
.getTechSteps()
.then((list) => {
if (!cancelled) setTechSteps(list);
})
.catch(() => {
if (!cancelled) setTechSteps([]);
});
return () => {
cancelled = true;
};
}, []);
// Fetched unconditionally on mount — the Ingrédients/Ustensiles sections
// render alongside the technique picker from the start (see this
// component's own doc comment on the merged editor), so there's no later
// point to defer this to anymore.
useEffect(() => {
if (catalogs !== null) return;
let cancelled = false;
Promise.all([apiClient.getIngredients(), apiClient.getUnits(), apiClient.getUtensils()])
.then(([ingredients, units, utensils]) => {
if (!cancelled) setCatalogs({ ingredients, units, utensils });
})
.catch(() => {
if (!cancelled) setCatalogs({ ingredients: [], units: [], utensils: [] });
});
return () => {
cancelled = true;
};
}, [catalogs]);
// Consumes a span `StepDescription` just resolved on this popover's
// behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the
// matching sub-picker and clears the "awaiting a selection" hint.
useEffect(() => {
if (resolvedMetadataSpan === null) return;
setActiveSpan({
kind: resolvedMetadataSpan.kind,
range: resolvedMetadataSpan.range,
text: resolvedMetadataSpan.text,
});
setAwaitingSpanFor(null);
setPickedIngredientId(null);
setSpanQuantity("");
setSpanUnitId(null);
// Depends on the whole object, not just `.nonce` — `StepDescription`
// only ever calls its setter with a brand-new object (never mutates
// one in place), so reference equality alone already gives this the
// "fires once per fresh selection" behavior `nonce` documents, with no
// need to silence the exhaustive-deps lint to get there.
}, [resolvedMetadataSpan]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
async function removeMatch() {
setIsSubmitting(true);
setError(null);
try {
const result = await apiClient.submitTechStepCorrection(recipeId, stepId, {
start: range.start,
end: range.end,
previousTechStepId,
correctedTechStepId: null,
});
onSubmitted(result);
onClose();
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setError(errorMessageService.getLabel(code));
setIsSubmitting(false);
}
}
async function confirm() {
if (selectedTechStepId === null) return;
setIsSubmitting(true);
setError(null);
try {
const result = await apiClient.submitTechStepCorrection(recipeId, stepId, {
start: range.start,
end: range.end,
previousTechStepId,
correctedTechStepId: selectedTechStepId,
...(metadataTouched ? { ingredients: pendingIngredients, utensils: pendingUtensils } : {}),
});
onSubmitted(result);
onClose();
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setError(errorMessageService.getLabel(code));
setIsSubmitting(false);
}
}
function requestSpan(kind: "ingredient" | "utensil") {
setAwaitingSpanFor(kind);
onRequestSpan(kind);
}
function cancelSpanSelection() {
setAwaitingSpanFor(null);
setActiveSpan(null);
setPickedIngredientId(null);
}
function confirmIngredientSpan() {
if (activeSpan === null || pickedIngredientId === null) return;
const trimmed = spanQuantity.trim();
const parsedQuantity = trimmed.length > 0 ? Number(trimmed) : null;
setPendingIngredients((prev) => [
...prev,
{
ingredientId: pickedIngredientId,
quantity:
parsedQuantity !== null && Number.isFinite(parsedQuantity) ? parsedQuantity : null,
unitId: spanUnitId,
start: activeSpan.range.start,
end: activeSpan.range.end,
},
]);
setMetadataTouched(true);
setActiveSpan(null);
setPickedIngredientId(null);
}
function confirmUtensilSpan(utensilId: number) {
if (activeSpan === null) return;
setPendingUtensils((prev) => [
...prev,
{ utensilId, start: activeSpan.range.start, end: activeSpan.range.end },
]);
setMetadataTouched(true);
setActiveSpan(null);
}
function removeIngredient(index: number) {
setPendingIngredients((prev) => prev.filter((_, i) => i !== index));
setMetadataTouched(true);
}
function removeUtensil(index: number) {
setPendingUtensils((prev) => prev.filter((_, i) => i !== index));
setMetadataTouched(true);
}
const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i]));
const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u]));
const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u]));
return (
<div className="tech-step-correction-popover" ref={popoverRef}>
<p className="tech-step-correction-popover__selection">
{t("recipes.techStepCorrection.selectionLabel", { text: selectedText })}
</p>
{awaitingSpanFor !== null ? (
<p className="tech-step-correction-popover__hint">
{t("recipes.techStepCorrection.selectSpanHint")}
</p>
) : activeSpan !== null ? (
<div className="tech-step-correction-popover__span-picker">
<p className="tech-step-correction-popover__selection">
{t("recipes.techStepCorrection.selectionLabel", { text: activeSpan.text })}
</p>
{activeSpan.kind === "ingredient" ? (
pickedIngredientId === null ? (
<CatalogSearchPicker
items={(catalogs?.ingredients ?? []).map((ingredient) => ({
id: ingredient.id,
label: t(`catalog.ingredients.${ingredient.key}`),
}))}
onSelect={setPickedIngredientId}
placeholder={t("recipes.form.searchIngredientPlaceholder")}
emptyLabel={t("recipes.form.noIngredientFound")}
/>
) : (
<div className="tech-step-correction-popover__quantity-line">
<input
type="number"
min="0"
step="any"
value={spanQuantity}
onChange={(e) => setSpanQuantity(e.target.value)}
aria-label={t("recipes.form.quantityLabel")}
/>
<select
value={spanUnitId ?? ""}
onChange={(e) => setSpanUnitId(e.target.value ? Number(e.target.value) : null)}
aria-label={t("recipes.form.unitLabel")}
>
<option value="">{t("recipes.form.unitPlaceholder")}</option>
{(catalogs?.units ?? []).map((unit) => (
<option key={unit.id} value={unit.id}>
{t(`catalog.units.${unit.key}`)}
</option>
))}
</select>
<button type="button" onClick={confirmIngredientSpan}>
{t("recipes.techStepCorrection.addToList")}
</button>
</div>
)
) : (
<CatalogSearchPicker
items={(catalogs?.utensils ?? []).map((utensil) => ({
id: utensil.id,
label: t(`catalog.utensils.${utensil.key}`),
}))}
onSelect={confirmUtensilSpan}
placeholder={t("recipes.techStepCorrection.searchUtensilPlaceholder")}
emptyLabel={t("recipes.techStepCorrection.noUtensilFound")}
/>
)}
<button
type="button"
className="tech-step-correction-popover__cancel"
onClick={cancelSpanSelection}
>
{t("recipes.techStepCorrection.cancelSpanSelection")}
</button>
</div>
) : techSteps === null ? (
<p>{t("recipes.loading")}</p>
) : (
<div className="tech-step-correction-popover__confirm">
<section className="tech-step-correction-popover__technique-section">
<div className="tech-step-correction-popover__technique-header">
<h4>{t("recipes.techStepCorrection.techniqueSection")}</h4>
{previousTechStepId !== null && (
<button
type="button"
disabled={isSubmitting}
onClick={removeMatch}
className="tech-step-correction-popover__remove"
>
{t("recipes.techStepCorrection.removeMatch")}
</button>
)}
</div>
<p className="tech-step-correction-popover__chosen-technique">
{selectedTechStepId !== null
? t("recipes.techStepCorrection.currentTechnique", {
technique: t(
`catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`,
),
})
: t("recipes.techStepCorrection.noTechniqueSelected")}
</p>
<CatalogSearchPicker
items={techSteps.map((techStep) => ({
id: techStep.id,
label: t(`catalog.techSteps.${techStep.key}`),
}))}
selectedId={selectedTechStepId ?? undefined}
onSelect={setSelectedTechStepId}
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
/>
</section>
<section className="tech-step-correction-popover__metadata-section">
<h4>{t("recipes.techStepCorrection.ingredientsSection")}</h4>
<ul className="tech-step-correction-popover__chips">
{pendingIngredients.map((ingredient, index) => {
const view = ingredientById.get(ingredient.ingredientId);
const unit =
ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
const label = view ? t(`catalog.ingredients.${view.key}`) : "…";
return (
// biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
<li key={index} className="tech-step-correction-popover__chip">
{ingredient.quantity !== null ? `${ingredient.quantity} ` : ""}
{unit ? `${t(`catalog.units.${unit.key}`)} ` : ""}
{label}
<button
type="button"
onClick={() => removeIngredient(index)}
title={t("recipes.techStepCorrection.removeIngredient")}
disabled={isSubmitting}
>
</button>
</li>
);
})}
</ul>
<button type="button" onClick={() => requestSpan("ingredient")} disabled={isSubmitting}>
{t("recipes.techStepCorrection.addIngredient")}
</button>
</section>
<section className="tech-step-correction-popover__metadata-section">
<h4>{t("recipes.techStepCorrection.utensilsSection")}</h4>
<ul className="tech-step-correction-popover__chips">
{pendingUtensils.map((utensil, index) => {
const view = utensilById.get(utensil.utensilId);
return (
// biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient chip list above.
<li key={index} className="tech-step-correction-popover__chip">
{view ? t(`catalog.utensils.${view.key}`) : "…"}
<button
type="button"
onClick={() => removeUtensil(index)}
title={t("recipes.techStepCorrection.removeUtensil")}
disabled={isSubmitting}
>
</button>
</li>
);
})}
</ul>
<button type="button" onClick={() => requestSpan("utensil")} disabled={isSubmitting}>
{t("recipes.techStepCorrection.addUtensil")}
</button>
</section>
<button
type="button"
className="tech-step-correction-popover__confirm-button"
onClick={confirm}
disabled={isSubmitting || selectedTechStepId === null}
>
{t("recipes.techStepCorrection.confirm")}
</button>
</div>
)}
{error && <p className="field-error">{error}</p>}
<button
type="button"
className="tech-step-correction-popover__cancel"
onClick={onClose}
disabled={isSubmitting}
>
{t("recipes.techStepCorrection.cancel")}
</button>
</div>
);
}

View file

@ -18,6 +18,8 @@ export interface DescriptionSegment {
techStep: StepTechStepView["techStep"] | null; techStep: StepTechStepView["techStep"] | null;
/** Always `false` when `techStep` is `null`. */ /** Always `false` when `techStep` is `null`. */
isKeyword: boolean; isKeyword: boolean;
/** Mirrors the source `StepTechStepView.source` this segment came from — `null` when `techStep` is `null` (nothing to attribute a source to). See `StepDescription.tsx` for how `"auto"` vs `"manual"` render differently. */
source: StepTechStepView["source"] | null;
} }
/** /**
@ -26,34 +28,54 @@ export interface DescriptionSegment {
* (the keyword) and, when present, `contextStart`/`contextEnd` (the wider * (the keyword) and, when present, `contextStart`/`contextEnd` (the wider
* clause it was found in see `StepTechStepView`, resolved server-side by * clause it was found in see `StepTechStepView`, resolved server-side by
* `tech-step-matcher.ts`'s `matchTechStepSpans`). An entry with no context * `tech-step-matcher.ts`'s `matchTechStepSpans`). An entry with no context
* (older data, saved before that column pair existed see * (older data, saved before that column pair existed, or a manual
* `StepTechStep`'s schema doc comment) degrades to a keyword-only segment, * correction see `StepTechStep`'s schema doc comment) degrades to a
* same as before context spans existed at all. * keyword-only segment, same as before context spans existed at all.
* *
* `techSteps` is expected already sorted by `start` (the API returns it in * `techSteps` is expected already sorted by `start` (the API returns it in
* `StepTechStep.order`, which *is* reading order see that model's schema * `StepTechStep.order`, which *is* reading order see that model's schema
* doc comment) but this re-sorts defensively (by context start when * doc comment) but this re-sorts defensively by each entry's own tight
* present, since context always starts at or before its own keyword) * `start` rather than assuming it, and silently drops any entry whose own
* rather than assuming it, and silently drops any entry whose bounds don't * bounds don't make sense against `description` or a previously-accepted
* make sense against `description` or a previously-accepted entry's own * entry's own tight keyword span a malformed/out-of-date span degrades to
* bounds a malformed/out-of-date span degrades to "just don't highlight * "just don't highlight that one" rather than a garbled slice or a crash.
* that one" rather than a garbled slice or a crash. *
* Two entries' tight keyword spans are never allowed to overlap (the later
* one is dropped, same as always), but two entries' wider *context* clauses
* are allowed to overlap each other and are silently clipped to make room
* context is cosmetic only (`StepDescription.tsx` renders it identically to
* plain text) and must never cost a *different* entry its own real keyword
* highlight. This matters far more than it looks: a clause `splitIntoClauses`
* (`tech-step-matcher.ts`) found only one NER candidate in gets that
* candidate's context spanning the *entire* clause often the entire
* description so without clipping, a single auto-detected match anywhere
* in a step could silently swallow every manual correction added anywhere
* else in that same step's description, with no error, just an unstyled
* word in the rendered text. Found via live testing: a "simmer" match's
* whole-description context ate a manual "setAside" correction added to a
* later, otherwise-plain word in the same step.
*/ */
export function splitDescriptionByTechSteps( export function splitDescriptionByTechSteps(
description: string, description: string,
techSteps: StepTechStepView[], techSteps: StepTechStepView[],
): DescriptionSegment[] { ): DescriptionSegment[] {
const sorted = [...techSteps].sort( const sorted = [...techSteps].sort((a, b) => a.start - b.start);
(a, b) => (a.contextStart ?? a.start) - (b.contextStart ?? b.start),
);
const segments: DescriptionSegment[] = []; // First pass: decide which entries survive at all, using only each
let cursor = 0; // entry's own tight keyword span for the cross-entry overlap check
for (const { techStep, start, end, contextStart, contextEnd } of sorted) { // (`start < keywordCursor`) — the one thing two independent matches are
// never allowed to genuinely share. An entry's own context is still
// validated against its *own* keyword span here (`wideStart > start`,
// `end > wideEnd`, `wideEnd > description.length`) — a self-inconsistent
// span is dropped regardless of any other entry.
const valid: StepTechStepView[] = [];
let keywordCursor = 0;
for (const entry of sorted) {
const { start, end, contextStart, contextEnd } = entry;
const wideStart = contextStart ?? start; const wideStart = contextStart ?? start;
const wideEnd = contextEnd ?? end; const wideEnd = contextEnd ?? end;
if ( if (
wideStart < cursor || start < keywordCursor ||
wideStart > start || wideStart > start ||
start >= end || start >= end ||
end > wideEnd || end > wideEnd ||
@ -61,12 +83,31 @@ export function splitDescriptionByTechSteps(
) { ) {
continue; continue;
} }
valid.push(entry);
keywordCursor = end;
}
const segments: DescriptionSegment[] = [];
let cursor = 0;
for (const [index, entry] of valid.entries()) {
const { techStep, start, end, contextStart, contextEnd, source } = entry;
const next = valid[index + 1];
// Clipped against `cursor` (this entry can't render context over
// territory already emitted) and the next surviving entry's own tight
// `start` (this entry's context can't reach into a neighbor's real
// keyword span) — provably within `[cursor, start]`/`[end, next.start]`
// respectively given `valid`'s own non-overlapping-tight-span
// invariant from the first pass, so never produces a negative-length
// slice.
const wideStart = Math.max(contextStart ?? start, cursor);
const wideEnd = Math.min(contextEnd ?? end, next?.start ?? description.length);
if (wideStart > cursor) { if (wideStart > cursor) {
segments.push({ segments.push({
text: description.slice(cursor, wideStart), text: description.slice(cursor, wideStart),
techStep: null, techStep: null,
isKeyword: false, isKeyword: false,
source: null,
}); });
} }
if (start > wideStart) { if (start > wideStart) {
@ -74,16 +115,27 @@ export function splitDescriptionByTechSteps(
text: description.slice(wideStart, start), text: description.slice(wideStart, start),
techStep, techStep,
isKeyword: false, isKeyword: false,
source,
}); });
} }
segments.push({ text: description.slice(start, end), techStep, isKeyword: true }); segments.push({ text: description.slice(start, end), techStep, isKeyword: true, source });
if (wideEnd > end) { if (wideEnd > end) {
segments.push({ text: description.slice(end, wideEnd), techStep, isKeyword: false }); segments.push({
text: description.slice(end, wideEnd),
techStep,
isKeyword: false,
source,
});
} }
cursor = wideEnd; cursor = wideEnd;
} }
if (cursor < description.length) { if (cursor < description.length) {
segments.push({ text: description.slice(cursor), techStep: null, isKeyword: false }); segments.push({
text: description.slice(cursor),
techStep: null,
isKeyword: false,
source: null,
});
} }
return segments; return segments;
} }

View file

@ -0,0 +1,84 @@
import { type RefObject, useCallback } from "react";
/** A `[start, end)` character range into a step's original `description` string — same convention as `StepTechStepView.start`/`end`. */
export interface TextSelectionRange {
start: number;
end: number;
}
/**
* Reads the browser's current text selection, translated into a
* {@link TextSelectionRange} into a step's original `description` string
* the shape `POST /recipes/:id/steps/:stepId/corrections` expects (see
* `SubmitTechStepCorrectionInput`, `packages/shared`).
*
* Works by walking up from each end of the selection's `Range` to the
* nearest ancestor carrying a `data-offset` attribute set by
* `StepDescription`'s editable mode on every {@link DescriptionSegment}'s
* own wrapping element (`<span>`/`<button>`, `StepDescription.tsx`), each
* wrapping exactly its own text run and nothing else. `data-offset`'s value
* is that segment's own absolute start offset into `description`; added to
* the in-node offset the `Range` reports, this gives an exact absolute
* offset without needing to serialize/re-measure any text.
*
* Deliberately *not* using the more common `Range.toString().length`-from-
* the-container's-start technique for this problem `StepDescription`
* always renders a `Tooltip` bubble alongside a keyword segment's own
* `<button>` (`Tooltip.tsx`, hidden via CSS, not removed from the DOM),
* whose text would silently pad that count past any keyword segment,
* corrupting every offset downstream of one.
*/
export function useTextSelection(containerRef: RefObject<HTMLElement | null>): {
getSelectionRange: () => TextSelectionRange | null;
} {
const getSelectionRange = useCallback((): TextSelectionRange | null => {
const selection = window.getSelection();
const container = containerRef.current;
if (!selection || selection.isCollapsed || selection.rangeCount === 0 || !container) {
return null;
}
const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) return null;
const start = resolveOffset(container, range.startContainer, range.startOffset);
const end = resolveOffset(container, range.endContainer, range.endOffset);
if (start === null || end === null || start === end) return null;
return start < end ? { start, end } : { start: end, end: start };
}, [containerRef]);
return { getSelectionRange };
}
/**
* Resolves `(node, nodeOffset)` (one end of a DOM `Range`, in the DOM's own
* mixed node/character-index convention) to an absolute character offset
* into `description`, or `null` if `node` isn't inside a segment
* `StepDescription` wrapped with `data-offset` at all a selection edge
* that lands on whitespace/structure outside any segment shouldn't occur
* given every segment is wrapped, but this degrades to "no valid
* selection" rather than a wrong span or a crash if it somehow does.
*/
function resolveOffset(container: HTMLElement, node: Node, nodeOffset: number): number | null {
// A `Range` boundary that lands exactly on a segment's own wrapping
// element (rather than diving into its single Text child) reports
// `nodeOffset` as a *child index* (`0` or `1`, since every segment wraps
// exactly one Text node) — not a character offset. Resolved to the
// matching character offset (segment start vs. segment end) up front, so
// the walk below only ever deals in character offsets from here on.
let charOffset = nodeOffset;
if (node instanceof HTMLElement) {
const textChild = node.firstChild;
charOffset = nodeOffset > 0 ? (textChild?.textContent?.length ?? 0) : 0;
}
let current: Node | null = node;
while (current && current !== container) {
if (current instanceof HTMLElement && current.dataset.offset !== undefined) {
return Number(current.dataset.offset) + charOffset;
}
current = current.parentNode;
}
return null;
}

View file

@ -2,7 +2,26 @@
"common": { "common": {
"saving": "Enregistrement…", "saving": "Enregistrement…",
"saved": "Enregistré ✓", "saved": "Enregistré ✓",
"loadError": "Impossible de charger le planning, réessayez plus tard" "loadError": "Impossible de charger le planning, réessayez plus tard",
"weekNav": {
"thisWeek": "Cette semaine",
"prevWeek": "Semaine précédente",
"nextWeek": "Semaine suivante",
"label": "Semaine du {{range}}"
},
"calendar": {
"prevMonth": "Mois précédent",
"nextMonth": "Mois suivant"
},
"days": {
"lundi": "Lundi",
"mardi": "Mardi",
"mercredi": "Mercredi",
"jeudi": "Jeudi",
"vendredi": "Vendredi",
"samedi": "Samedi",
"dimanche": "Dimanche"
}
}, },
"errors": { "errors": {
"VALIDATION_ERROR": "Erreur de validation", "VALIDATION_ERROR": "Erreur de validation",
@ -23,6 +42,10 @@
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas", "INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
"UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas", "UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas",
"SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas", "SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas",
"STEP_NOT_FOUND": "Cette étape n'existe pas",
"TECH_STEP_NOT_FOUND": "Cette technique n'existe pas",
"UTENSIL_NOT_FOUND": "Un des ustensiles sélectionnés n'existe pas",
"INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard" "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
}, },
"auth": { "auth": {
@ -99,25 +122,6 @@
"planning": { "planning": {
"title": "Planning de la semaine", "title": "Planning de la semaine",
"loading": "Chargement du planning…", "loading": "Chargement du planning…",
"weekNav": {
"thisWeek": "Cette semaine",
"prevWeek": "Semaine précédente",
"nextWeek": "Semaine suivante",
"label": "Semaine du {{range}}"
},
"calendar": {
"prevMonth": "Mois précédent",
"nextMonth": "Mois suivant"
},
"days": {
"lundi": "Lundi",
"mardi": "Mardi",
"mercredi": "Mercredi",
"jeudi": "Jeudi",
"vendredi": "Vendredi",
"samedi": "Samedi",
"dimanche": "Dimanche"
},
"meals": { "meals": {
"petit-dejeuner": "Petit-déjeuner", "petit-dejeuner": "Petit-déjeuner",
"collation": "Collation", "collation": "Collation",
@ -160,6 +164,30 @@
"cancelDeleteButton": "Annuler", "cancelDeleteButton": "Annuler",
"ingredientsTitle": "Ingrédients", "ingredientsTitle": "Ingrédients",
"stepsTitle": "Préparation", "stepsTitle": "Préparation",
"techStepCorrection": {
"selectionLabel": "« {{text}} »",
"removeMatch": "Aucune technique ici",
"cancel": "Annuler",
"manualTooltip": "{{technique}} (correction manuelle)",
"discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger.",
"confirm": "Valider",
"techniqueSection": "Technique",
"currentTechnique": "Technique retenue : {{technique}}",
"noTechniqueSelected": "Aucune technique sélectionnée.",
"searchTechniquePlaceholder": "Rechercher une technique…",
"noTechniqueFound": "Aucune technique trouvée.",
"ingredientsSection": "Ingrédients",
"utensilsSection": "Ustensiles",
"addIngredient": "+ Ajouter un ingrédient",
"addUtensil": "+ Ajouter un ustensile",
"removeIngredient": "Retirer cet ingrédient",
"removeUtensil": "Retirer cet ustensile",
"selectSpanHint": "Sélectionnez le passage de texte concerné dans la description ci-dessus…",
"cancelSpanSelection": "Annuler la sélection",
"searchUtensilPlaceholder": "Rechercher un ustensile…",
"noUtensilFound": "Aucun ustensile trouvé.",
"addToList": "Ajouter"
},
"tabs": { "tabs": {
"favoris": "Favoris", "favoris": "Favoris",
"perso": "Perso", "perso": "Perso",
@ -174,10 +202,11 @@
"sources": { "sources": {
"searchPlaceholder": "Rechercher…", "searchPlaceholder": "Rechercher…",
"empty": "Aucune recette trouvée.", "empty": "Aucune recette trouvée.",
"loadMore": "Voir plus",
"alreadyImported": "Déjà importée", "alreadyImported": "Déjà importée",
"loading": "Chargement…", "loading": "Chargement…",
"loadError": "Impossible de charger cette source pour le moment.", "loadError": "Impossible de charger cette source pour le moment.",
"loadMoreError": "Impossible de charger la suite pour le moment.",
"retry": "Réessayer",
"detail": { "detail": {
"viewSource": "Voir sur le site d'origine" "viewSource": "Voir sur le site d'origine"
}, },
@ -278,7 +307,8 @@
}, },
"shoppingList": { "shoppingList": {
"title": "Liste de courses", "title": "Liste de courses",
"comingSoon": "Cette section arrive bientôt." "loading": "Chargement de la liste de courses…",
"empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning."
}, },
"account": { "account": {
"title": "Compte", "title": "Compte",
@ -413,7 +443,87 @@
"preheat": "Préchauffer", "preheat": "Préchauffer",
"bake": "Cuire au four", "bake": "Cuire au four",
"plate": "Dresser", "plate": "Dresser",
"coat": "Napper" "coat": "Napper",
"baste": "Arroser",
"appertize": "Appertiser",
"whiskPale": "Blanchir (jaunes d'œufs)",
"goldenBrown": "Blondir",
"braise": "Braiser",
"truss": "Brider",
"caramelize": "Caraméliser",
"score": "Cerner",
"lineMold": "Chemiser",
"clarify": "Clarifier",
"compote": "Compoter",
"concasse": "Concasser",
"confit": "Confire",
"julienne": "Couper en julienne",
"brunoise": "Couper en brunoise",
"mirepoix": "Couper en mirepoix",
"paysanne": "Couper en paysanne",
"blindBake": "Cuire à blanc",
"bainMarie": "Cuire au bain-marie",
"smother": "Cuire à l'étouffée",
"decant": "Décanter",
"dilute": "Délayer",
"punchDown": "Dégazer",
"disgorge": "Dégorger",
"loosen": "Détendre",
"shellEgg": "Écaler",
"scald": "Échauder",
"pod": "Écosser",
"emulsify": "Émulsionner",
"hollowOut": "Évider",
"shock": "Frapper",
"setGel": "Gélifier",
"glaze": "Glacer",
"thicken": "Lier",
"filet": "Lever les filets",
"proof": "Laisser pousser",
"peelBlanch": "Monder",
"whipUp": "Monter",
"moisten": "Mouiller",
"pasteurize": "Pasteuriser",
"poach": "Pocher",
"reduce": "Réduire",
"rubIn": "Sabler",
"dustWithFlour": "Singer",
"sweat": "Suer",
"sift": "Tamiser",
"toast": "Torréfier",
"zest": "Zester"
},
"utensils": {
"pan": "Poêle",
"saucepan": "Casserole",
"pot": "Marmite",
"knife": "Couteau",
"whisk": "Fouet",
"bowl": "Saladier",
"bakingSheet": "Plaque de cuisson",
"mold": "Moule",
"colander": "Passoire",
"cuttingBoard": "Planche à découper",
"oven": "Four",
"blender": "Blender",
"mixer": "Batteur",
"spatula": "Spatule",
"ladle": "Louche",
"grater": "Râpe",
"rollingPin": "Rouleau à pâtisserie",
"lid": "Couvercle",
"tongs": "Pince de cuisine",
"peeler": "Économe",
"sieve": "Tamis",
"foodProcessor": "Robot ménager",
"steamerBasket": "Panier vapeur",
"skewer": "Brochette",
"pastryBrush": "Pinceau de cuisine",
"ramekin": "Ramequin",
"dish": "Plat",
"wok": "Wok",
"thermometer": "Thermomètre",
"mandoline": "Mandoline"
}, },
"allergens": { "allergens": {
"gluten": "Gluten", "gluten": "Gluten",

View file

@ -1,11 +1,4 @@
import { import { DateTime, formatDateOnly, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
addWeeks,
buildCalendarMonth,
DateTime,
formatDateOnly,
getWeekStart,
toDateOnly,
} from "@batch-cooking/date-tools";
import { import {
MEALS, MEALS,
type Meal, type Meal,
@ -13,10 +6,11 @@ import {
type PlanningView, type PlanningView,
WEEK_DAYS, WEEK_DAYS,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { apiClient } from "../../api/client"; import { apiClient } from "../../api/client";
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog"; import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
import { WeekNavigator } from "../../features/planning/WeekNavigator";
import "./planning-page.scss"; import "./planning-page.scss";
/** Load state for the `GET /planning` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */ /** Load state for the `GET /planning` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */
@ -49,7 +43,7 @@ export function PlanningPage() {
// closed. Mounting the dialog only while this is set (rather than an // closed. Mounting the dialog only while this is set (rather than an
// always-mounted `isOpen` toggle) resets its internal filter/search // always-mounted `isOpen` toggle) resets its internal filter/search
// state for free on every open, same convention as `WeekNavigator`'s own // state for free on every open, same convention as `WeekNavigator`'s own
// `CalendarPopover` below. // `CalendarPopover` (features/planning/WeekNavigator.tsx).
const [openSlot, setOpenSlot] = useState<PlanningSlot | null>(null); const [openSlot, setOpenSlot] = useState<PlanningSlot | null>(null);
useEffect(() => { useEffect(() => {
@ -144,160 +138,6 @@ export function PlanningPage() {
); );
} }
/** "17 au 23 août 2026" — collapses the month/year to just the end date when both ends of the week share it, spells it out on both ends otherwise (e.g. a week straddling two months). */
function formatWeekRange(weekStart: DateTime): string {
const weekEnd = weekStart.plus({ days: 6 });
const sameMonth = weekStart.hasSame(weekEnd, "month");
const startLabel = weekStart.toLocaleString(
sameMonth ? { day: "numeric" } : { day: "numeric", month: "long" },
{ locale: "fr" },
);
const endLabel = weekEnd.toLocaleString(
{ day: "numeric", month: "long", year: "numeric" },
{ locale: "fr" },
);
return `${startLabel} au ${endLabel}`;
}
/** Arrows + clickable label opening {@link CalendarPopover} — the week-selection UI at the top of the page. */
function WeekNavigator({
weekStart,
onChangeWeek,
}: {
weekStart: DateTime;
onChangeWeek: (weekStart: DateTime) => void;
}) {
const { t } = useTranslation();
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const isThisWeek = weekStart.hasSame(getWeekStart(DateTime.utc()), "day");
return (
<div className="week-nav">
<button
type="button"
className="week-nav__arrow"
title={t("planning.weekNav.prevWeek")}
onClick={() => onChangeWeek(addWeeks(weekStart, -1))}
>
</button>
<button
type="button"
className="week-nav__label"
onClick={() => setIsCalendarOpen((open) => !open)}
>
📅 {t("planning.weekNav.label", { range: formatWeekRange(weekStart) })}
{isThisWeek && <span className="today-badge">{t("planning.weekNav.thisWeek")}</span>}
</button>
<button
type="button"
className="week-nav__arrow"
title={t("planning.weekNav.nextWeek")}
onClick={() => onChangeWeek(addWeeks(weekStart, 1))}
>
</button>
{isCalendarOpen && (
<CalendarPopover
selectedWeekStart={weekStart}
onSelectDay={(day) => {
onChangeWeek(getWeekStart(day));
setIsCalendarOpen(false);
}}
onClose={() => setIsCalendarOpen(false)}
/>
)}
</div>
);
}
/** Month calendar letting the visitor jump to any week at once — selecting a day selects its whole (Monday-first) week. Closes itself on an outside click. */
function CalendarPopover({
selectedWeekStart,
onSelectDay,
onClose,
}: {
selectedWeekStart: DateTime;
onSelectDay: (day: DateTime) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
// Its own state: browsing to a different month to pick a week there
// shouldn't jump back every render — only re-anchors when the popover is
// first opened (`selectedWeekStart` at that point), not while it's open.
const [visibleMonth, setVisibleMonth] = useState(() => selectedWeekStart.startOf("month"));
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const today = toDateOnly(DateTime.utc());
const selectedWeekEnd = selectedWeekStart.plus({ days: 6 });
const weeks = buildCalendarMonth(visibleMonth);
return (
<div className="calendar-popover" ref={popoverRef}>
<div className="calendar-popover__header">
<button
type="button"
title={t("planning.calendar.prevMonth")}
onClick={() => setVisibleMonth((month) => month.minus({ months: 1 }))}
>
</button>
<span>
{visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })}
</span>
<button
type="button"
title={t("planning.calendar.nextMonth")}
onClick={() => setVisibleMonth((month) => month.plus({ months: 1 }))}
>
</button>
</div>
<div className="calendar-grid">
{WEEK_DAYS.map((weekDay) => (
<span key={weekDay} className="calendar-grid__weekday">
{t(`planning.days.${weekDay}`).charAt(0)}
</span>
))}
{weeks.flat().map((day) => {
const classNames = ["calendar-grid__day"];
if (!day.hasSame(visibleMonth, "month")) classNames.push("calendar-grid__day--muted");
if (day >= selectedWeekStart && day <= selectedWeekEnd) {
classNames.push("calendar-grid__day--in-selected-week");
}
if (day.hasSame(today, "day")) classNames.push("calendar-grid__day--today");
return (
<button
key={day.toISO()}
type="button"
className={classNames.join(" ")}
onClick={() => onSelectDay(day)}
>
{day.day}
</button>
);
})}
</div>
</div>
);
}
/** The week grid itself — 7 day columns × 5 meal rows. */ /** The week grid itself — 7 day columns × 5 meal rows. */
function PlanningGrid({ function PlanningGrid({
weekStart, weekStart,
@ -323,7 +163,7 @@ function PlanningGrid({
<th /> <th />
{days.map(({ weekDay, date }) => ( {days.map(({ weekDay, date }) => (
<th key={weekDay} className={date.hasSame(today, "day") ? "today" : undefined}> <th key={weekDay} className={date.hasSame(today, "day") ? "today" : undefined}>
<span className="day-name">{t(`planning.days.${weekDay}`)}</span> <span className="day-name">{t(`common.days.${weekDay}`)}</span>
<span className="day-date">{date.day}</span> <span className="day-date">{date.day}</span>
</th> </th>
))} ))}

View file

@ -38,143 +38,11 @@
} }
} }
// --- Week navigator (arrows + clickable label opening the calendar) -------
.week-nav {
position: relative;
display: flex;
align-items: center;
gap: var(--space-xs);
&__arrow {
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--font-size-md);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
}
&__label {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 0.45rem var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font-weight: 600;
font-size: var(--font-size-sm);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
}
}
.today-badge {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
padding: 0.1rem 0.4rem;
border-radius: var(--radius-pill);
}
// --- Calendar popover -------------------------------------------------------
.calendar-popover {
position: absolute;
top: calc(100% + var(--space-xs));
right: 0;
z-index: 10;
width: 18rem;
padding: var(--space-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-sm);
font-weight: 700;
font-size: var(--font-size-sm);
text-transform: capitalize;
button {
width: 1.6rem;
height: 1.6rem;
border: none;
background: none;
cursor: pointer;
font-size: var(--font-size-base);
color: var(--color-text-muted);
border-radius: var(--radius-base);
&:hover {
background: var(--color-surface-alt);
}
}
}
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
&__weekday {
text-align: center;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-weight: 600;
padding-bottom: var(--space-xs);
}
&__day {
aspect-ratio: 1;
display: grid;
place-items: center;
font-size: var(--font-size-sm);
border-radius: var(--radius-base);
cursor: pointer;
color: var(--color-text);
border: none;
background: none;
font: inherit;
&:hover {
background: var(--color-surface-alt);
}
&--muted {
color: var(--color-border);
}
&--in-selected-week {
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border-radius: 0;
}
&--today {
box-shadow: inset 0 0 0 2px var(--color-primary);
font-weight: 700;
}
}
}
// --- The grid itself -------------------------------------------------------- // --- The grid itself --------------------------------------------------------
// (Week navigator + calendar popover styles now live in
// features/planning/week-navigator.scss, imported by WeekNavigator.tsx
// directly extracted once ShoppingListPage started reusing that
// component too.)
.planning-grid-wrapper { .planning-grid-wrapper {
flex: 1; flex: 1;
min-height: 0; min-height: 0;

View file

@ -1,10 +1,114 @@
import { DateTime, formatDateOnly, getWeekStart } from "@batch-cooking/date-tools";
import type { ShoppingListItemView, ShoppingListView } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ComingSoonPage } from "../../components/ui/ComingSoonPage"; import { apiClient } from "../../api/client";
import { WeekNavigator } from "../../features/planning/WeekNavigator";
import {
CategoryIcon,
IngredientTypeIcon,
} from "../../features/recipes/ingredients/ingredient-icons";
import { formatShoppingListQuantity, groupShoppingListItems } from "./shopping-list";
import "./shopping-list-page.scss";
/** Shopping list section — routed at `/liste-de-courses`. No backend yet, stub for now. */ /** Load state for the `GET /shopping-list` call — same discriminated-union shape as `PlanningPage`'s own `PlanningState`. */
type ShoppingListState =
| { status: "loading" }
| { status: "loaded"; list: ShoppingListView }
| { status: "error" };
/**
* Shopping list section routed at `/liste-de-courses`. Every ingredient
* line of every recipe planned for a selectable week, aggregated server-side
* (`GET /shopping-list`, see the API's `shopping-list.service.ts`) into one
* quantity per (ingredient, unit) pair, grouped by supermarket aisle for
* display. Deliberately simple by design a read-only list, no
* checkboxes/crossing-off state: the source of truth for what's needed is
* the planning itself, not a separate to-do list this page would have to
* keep in sync with it.
*/
export function ShoppingListPage() { export function ShoppingListPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
const [state, setState] = useState<ShoppingListState>({ status: "loading" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
apiClient
.getShoppingListForWeek(formatDateOnly(weekStart))
.then((list) => {
if (!cancelled) setState({ status: "loaded", list });
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [weekStart]);
return ( return (
<ComingSoonPage title={t("shoppingList.title")} description={t("shoppingList.comingSoon")} /> <div className="shopping-list-page">
<div className="shopping-list-page__header">
<h1>{t("shoppingList.title")}</h1>
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
</div>
{state.status === "loading" && (
<p className="shopping-list-page__status">{t("shoppingList.loading")}</p>
)}
{state.status === "error" && (
<p className="shopping-list-page__status shopping-list-page__status--error">
{t("common.loadError")}
</p>
)}
{state.status === "loaded" && <ShoppingListItems items={state.list.items} />}
</div>
);
}
/** The list itself, grouped by aisle (see `groupShoppingListItems`) — or the empty-week message if nothing's planned. */
function ShoppingListItems({ items }: { items: ShoppingListItemView[] }) {
const { t } = useTranslation();
if (items.length === 0) {
return <p className="shopping-list-page__status">{t("shoppingList.empty")}</p>;
}
const groups = groupShoppingListItems(items, (item) =>
t(`catalog.ingredients.${item.ingredient.key}`),
);
return (
<div className="shopping-list">
{groups.map((group) => (
<section key={group.category} className="shopping-list__group">
<h2 className="shopping-list__group-title">
<CategoryIcon category={group.category} />
{t(`recipes.form.category.${group.category}`)}
</h2>
<ul className="shopping-list__items">
{group.items.map((item) => (
<li key={`${item.ingredient.id}-${item.unit.id}`} className="shopping-list__item">
<span className="shopping-list__item-icon" aria-hidden="true">
<IngredientTypeIcon icon={item.ingredient.icon} />
</span>
<span className="shopping-list__item-name">
{t(`catalog.ingredients.${item.ingredient.key}`)}
</span>
<span className="shopping-list__item-quantity">
{formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)}
</span>
</li>
))}
</ul>
</section>
))}
</div>
); );
} }

View file

@ -0,0 +1,102 @@
// =============================================================================
// Styles specific to ShoppingListPage colocated next to
// ShoppingListPage.tsx since nothing else uses these classes. Same page
// shell/status conventions as planning-page.scss (`.planning-page__header`/
// `__status`), a simple grouped list rather than a grid below it.
// =============================================================================
.shopping-list-page {
height: 100%;
display: flex;
flex-direction: column;
&__header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
&__status {
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
&__status--error {
color: var(--color-error);
}
}
// --- The list itself, grouped by aisle --------------------------------------
.shopping-list {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.shopping-list__group-title {
display: flex;
align-items: center;
gap: var(--space-xs);
margin: 0 0 var(--space-sm);
font-size: var(--font-size-md);
font-weight: 700;
color: var(--color-text);
svg {
width: 1.2rem;
height: 1.2rem;
color: var(--color-text-muted);
}
}
.shopping-list__items {
display: flex;
flex-direction: column;
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.shopping-list__item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--color-border);
&:last-child {
border-bottom: none;
}
&-icon {
flex-shrink: 0;
display: grid;
place-items: center;
color: var(--color-text-muted);
svg {
width: 1.25rem;
height: 1.25rem;
}
}
&-name {
flex: 1;
min-width: 0;
color: var(--color-text);
}
&-quantity {
flex-shrink: 0;
font-weight: 600;
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
}

View file

@ -0,0 +1,64 @@
import {
INGREDIENT_CATEGORIES,
type IngredientCategory,
type ShoppingListItemView,
} from "@batch-cooking/shared";
/** One aisle's worth of shopping list lines — see {@link groupShoppingListItems}. */
export interface ShoppingListGroup {
category: IngredientCategory;
items: ShoppingListItemView[];
}
/**
* Groups `items` by their ingredient's supermarket-aisle category (the same
* `IngredientCategory` the recipe form's `IngredientPicker` already browses
* by, see `ingredient-icons.tsx`'s `CategoryIcon`), in the app's canonical
* `INGREDIENT_CATEGORIES` order a shopping list read aisle-by-aisle is far
* more useful in-store than one flat list. Within a group, lines are sorted
* by `ingredientLabel` the caller's *already-translated* display name for
* that line, not the untranslated English `key` so alphabetical order
* reads correctly in French; kept as a parameter (rather than calling
* `useTranslation` in here) so this stays a pure function the component can
* unit test without mounting i18next, same "logic extracted from the .tsx"
* split as every other feature in this codebase.
*/
export function groupShoppingListItems(
items: ShoppingListItemView[],
ingredientLabel: (item: ShoppingListItemView) => string,
): ShoppingListGroup[] {
const byCategory = new Map<IngredientCategory, ShoppingListItemView[]>();
for (const item of items) {
const category = item.ingredient.category;
const group = byCategory.get(category);
if (group) {
group.push(item);
} else {
byCategory.set(category, [item]);
}
}
const groups: ShoppingListGroup[] = [];
for (const category of INGREDIENT_CATEGORIES) {
const groupItems = byCategory.get(category);
if (!groupItems) continue;
groups.push({
category,
items: [...groupItems].sort((a, b) =>
ingredientLabel(a).localeCompare(ingredientLabel(b), "fr"),
),
});
}
return groups;
}
/**
* Formats an aggregated quantity for display French grouping/decimal
* conventions, at most 2 decimals (e.g. `"1,5"`, `"250"`) so summing several
* recipes' quantities (`shopping-list.service.ts`'s `aggregateShoppingList`,
* floating-point addition) never surfaces a long trailing-digit artifact
* like `"149.99999999999997"`.
*/
export function formatShoppingListQuantity(quantity: number): string {
return quantity.toLocaleString("fr-FR", { maximumFractionDigits: 2 });
}

View file

@ -27,9 +27,6 @@ services:
context: . context: .
dockerfile: apps/api/Dockerfile dockerfile: apps/api/Dockerfile
restart: unless-stopped restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment: environment:
NODE_ENV: production NODE_ENV: production
PORT: 3000 PORT: 3000
@ -45,8 +42,92 @@ services:
# authenticated request 401s despite login succeeding. See its doc # authenticated request 401s despite login succeeding. See its doc
# comment in apps/api/src/config/env.ts. # comment in apps/api/src/config/env.ts.
COOKIE_SECURE: ${COOKIE_SECURE:-} COOKIE_SECURE: ${COOKIE_SECURE:-}
# Shared with the `tech-step-llm-worker` service below — see
# requireInternalWorker's doc comment
# (apps/api/src/middlewares/require-internal-worker.ts). Unset by
# default: `/internal/tech-steps/*` fails closed rather than open
# for a deployment that doesn't run the worker at all.
INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-}
# Compose network service name, not localhost — same reasoning as
# DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback:
# tech-step-intent-service is a core dependency (see its own entry
# below), not an optional background job.
INTENT_SERVICE_BASE_URL: "http://tech-step-intent-service:8000"
INTENT_SERVICE_SECRET: ${INTENT_SERVICE_SECRET:?set INTENT_SERVICE_SECRET in .env}
ports: ports:
- "${APP_PORT:-3000}:3000" - "${APP_PORT:-3000}:3000"
depends_on:
postgres:
condition: service_healthy
tech-step-intent-service:
condition: service_healthy
# spaCy-based NER + intent classification microservice
# (services/tech-step-intent-service) — `app` delegates all tech-step
# detection to it over HTTP (see `IntentServiceClient`,
# apps/api/src/lib/recipe-matching/intent-service-client.ts). Unlike
# `tech-step-llm-worker` below, **not optional**: without it, `app` can no
# longer detect any cooking technique in a recipe step at all. No exposed
# port — reachable only from `app` on the compose network, nothing ever
# calls into it from outside.
tech-step-intent-service:
build:
context: .
dockerfile: services/tech-step-intent-service/Dockerfile
restart: unless-stopped
environment:
INTENT_SERVICE_SECRET: ${INTENT_SERVICE_SECRET:?set INTENT_SERVICE_SECRET in .env}
healthcheck:
# No curl/wget in the python:3.12-slim base image — a one-line Python
# request is the healthcheck for a service that's already guaranteed
# to have Python (see this service's Dockerfile).
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2)",
]
interval: 15s
timeout: 3s
retries: 5
# This service trains itself from scratch on every start (no model
# ever persisted to disk, see its own README) — `/health` only
# returns 200 once that's done, not just once the base spaCy models
# are loaded. Measured at ~540s (fr) / ~390s (en), ~930s combined,
# against the current ~74-technique corpus — each technique now has
# the *same* number of `utterances` per locale as every other
# (equalized to the corpus's own pre-existing max, 7/5 — see
# `training_data.py`'s own doc comment for why a flat, larger target
# like 20 was tried and reverted) — `start_period` generous enough
# that failing checks during that whole window never count against
# `retries` (which would otherwise flip this container to
# "unhealthy" mid-training, blocking `app`'s own `depends_on:
# condition: service_healthy` indefinitely).
start_period: 1200s
# Deliberately its own image, not built into `app`'s (see
# services/tech-step-llm-worker/Dockerfile's own doc comment) — a
# long-lived process with no exposed port (nothing ever calls *into* it,
# it only ever calls out to `app`). Optional: an `INTERNAL_WORKER_SECRET`-
# less deployment can omit this service entirely and `app` still runs
# fine, just without the offline audit/feedback-loop jobs.
tech-step-llm-worker:
build:
context: .
dockerfile: services/tech-step-llm-worker/Dockerfile
restart: unless-stopped
depends_on:
- app
environment:
API_BASE_URL: "http://app:3000"
INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:?set INTERNAL_WORKER_SECRET in .env to run this service}
TECH_STEP_WORKER_CRON: ${TECH_STEP_WORKER_CRON:-0 3 * * 0}
volumes:
# GGUF weights persist across restarts — see this service's own
# Dockerfile doc comment on its VOLUME declaration.
- tech_step_llm_worker_models:/worker/models
volumes: volumes:
postgres_data: postgres_data:
tech_step_llm_worker_models:

View file

@ -0,0 +1,661 @@
/**
* French display/matching labels for the `Ingredient` reference catalog
* (`apps/api/src/db/reference-seed-data.ts`'s `INGREDIENT_GROUPS`), keyed
* by the same `Ingredient.key` used throughout the app the French
* counterpart to {@link INGREDIENT_LABELS_EN} (catalog-labels-en.ts), used
* by `apps/api/src/lib/recipe-matching/ingredient-matcher.ts` to resolve
* free-text ingredient lines from French-language recipe sources (Marmiton,
* 750g, Manger Bouger) against our catalog.
*
* Deliberately **not** hand-authored from scratch: every value here is
* copied verbatim from `apps/web/src/locales/fr/translation.json`'s
* `catalog.ingredients` (the same French names already shown in the UI),
* not written fresh for matching purposes the way {@link INGREDIENT_LABELS_EN}
* was. That reuse is a deliberate trade-off, not an oversight: it guarantees
* every one of this catalog's ~550 ingredients gets *some* French matching
* label for free, at the cost of a few labels being phrased for display
* (a UI-friendly short name) rather than for how someone would actually
* write it in running recipe text e.g. `vanillaBean`'s "Vanille (gousse)"
* won't match "1 gousse de vanille" (matching is ordered see
* `matchIngredientName`'s own doc comment and "gousse" comes first in
* real usage, not "vanille"), which is exactly what
* {@link INGREDIENT_LABEL_SYNONYMS_FR} below exists to patch up, entry by
* entry, as real mismatches like that one turn up the ingredient still
* resolves correctly once a synonym in the natural word order is added, no
* change to the primary (display) label required.
*/
export const INGREDIENT_LABELS_FR: Record<string, string> = {
// Vegetables
tomato: "Tomate",
onion: "Oignon",
shallot: "Échalote",
garlic: "Ail",
carrot: "Carotte",
zucchini: "Courgette",
cucumber: "Concombre",
gherkins: "Cornichons",
bellPepper: "Poivron",
mushroom: "Champignon",
porcini: "Cèpes",
eggplant: "Aubergine",
broccoli: "Brocoli",
cauliflower: "Chou-fleur",
whiteCabbage: "Chou blanc",
redCabbage: "Chou rouge",
brusselsSprouts: "Chou de Bruxelles",
spinach: "Épinard",
swissChard: "Blette",
lettuce: "Salade",
arugula: "Roquette",
watercress: "Cresson",
leek: "Poireau",
celery: "Céleri",
radish: "Radis",
beetroot: "Betterave",
turnip: "Navet",
parsnip: "Panais",
greenBean: "Haricot vert",
pea: "Petit pois",
corn: "Maïs",
artichoke: "Artichaut",
fennel: "Fenouil",
endive: "Endive",
pumpkin: "Potiron",
butternutSquash: "Butternut",
asparagus: "Asperge",
avocado: "Avocat",
potato: "Pomme de terre",
sweetPotato: "Patate douce",
cherryTomato: "Tomates cerises",
bokChoy: "Pak-choï",
soybeanSprouts: "Germes de soja",
shiitake: "Shiitake",
daikon: "Daikon",
freshGreenChili: "Piment vert frais",
cardoon: "Cardon",
radicchio: "Chicorée rouge",
romanesco: "Chou romanesco",
kohlrabi: "Chou-rave",
napaCabbage: "Chou chinois",
celeriac: "Céleri-rave",
okra: "Gombo",
springOnion: "Oignon nouveau",
redKuriSquash: "Potimarron",
rutabaga: "Rutabaga",
samphire: "Salicorne",
salsify: "Salsifis",
lambsLettuce: "Mâche",
escarole: "Scarole",
// Fruits
lemon: "Citron",
lime: "Citron vert",
apple: "Pomme",
pear: "Poire",
banana: "Banane",
orange: "Orange",
clementine: "Clémentine",
grapefruit: "Pamplemousse",
strawberry: "Fraise",
raspberry: "Framboise",
blueberry: "Myrtille",
blackberry: "Mûre",
cherry: "Cerise",
apricot: "Abricot",
peach: "Pêche",
plum: "Prune",
grape: "Raisin",
melon: "Melon",
watermelon: "Pastèque",
pineapple: "Ananas",
mango: "Mangue",
kiwi: "Kiwi",
fig: "Figue",
date: "Datte",
lychee: "Litchi",
pomegranate: "Grenade",
rhubarb: "Rhubarbe",
quince: "Coing",
blackcurrant: "Cassis",
cranberry: "Canneberge",
redcurrant: "Groseille",
persimmon: "Kaki",
nectarine: "Nectarine",
tamarind: "Tamarin",
// Fresh herbs
basil: "Basilic",
parsley: "Persil",
thyme: "Thym",
rosemary: "Romarin",
bayLeaf: "Laurier",
chives: "Ciboulette",
freshCilantro: "Coriandre fraîche",
mint: "Menthe",
oregano: "Origan",
dill: "Aneth",
tarragon: "Estragon",
savory: "Sarriette",
marjoram: "Marjolaine",
sage: "Sauge",
chervil: "Cerfeuil",
ginger: "Gingembre",
lemongrass: "Citronnelle",
kaffirLime: "Combava",
// Meats
rabbit: "Lapin",
groundBeef: "Bœuf haché",
beefSteak: "Steak de bœuf",
beefRoast: "Rôti de bœuf",
vealCutlet: "Escalope de veau",
porkTenderloin: "Filet mignon de porc",
porkChop: "Côte de porc",
groundVeal: "Veau haché",
groundPork: "Porc haché",
groundLamb: "Agneau haché",
lamb: "Agneau",
legOfLamb: "Gigot d'agneau",
baconLardons: "Lardons",
bacon: "Bacon",
ham: "Jambon blanc",
curedHam: "Jambon cru",
sausage: "Saucisse",
chorizo: "Chorizo",
merguez: "Merguez",
prosciutto: "Prosciutto",
pancetta: "Pancetta",
mortadella: "Mortadelle",
salami: "Salami",
andouille: "Andouille",
andouillette: "Andouillette",
whitePudding: "Boudin blanc",
blackPudding: "Boudin noir",
cervelat: "Cervelas",
rillettes: "Rillettes",
dryCuredSausage: "Saucisson sec",
bayonneHam: "Jambon de Bayonne",
coppa: "Coppa",
rosetteSausage: "Rosette (saucisson)",
vealLiver: "Foie de veau",
vealKidneys: "Rognons de veau",
vealBrain: "Cervelle de veau",
vealSweetbread: "Ris de veau",
beefTongue: "Langue de bœuf",
tripe: "Tripes",
venison: "Cerf",
roeDeer: "Chevreuil",
wildBoar: "Sanglier",
horseMeat: "Cheval",
beefHeart: "Cœur de bœuf",
foieGras: "Foie gras",
beefMuzzle: "Museau de bœuf",
grisonsDriedBeef: "Viande des Grisons",
// Poultry
chicken: "Poulet",
groundChicken: "Poulet haché",
turkey: "Dinde",
groundTurkey: "Dinde hachée",
duck: "Canard",
duckBreast: "Magret de canard",
quail: "Caille",
guineaFowl: "Pintade",
goose: "Oie",
poultryLiver: "Foie de volaille",
capon: "Chapon",
pigeon: "Pigeon",
pheasant: "Faisan",
// Fish
salmon: "Saumon",
tuna: "Thon",
cod: "Cabillaud",
trout: "Truite",
sardine: "Sardine",
anchovy: "Anchois",
whiting: "Merlan",
surimi: "Surimi",
seaBass: "Bar (loup de mer)",
seaBream: "Dorade",
sole: "Sole",
turbot: "Turbot",
hake: "Merlu",
pollock: "Colin",
saithe: "Lieu noir",
haddock: "Églefin",
mackerel: "Maquereau",
herring: "Hareng",
redMullet: "Rouget",
skate: "Raie",
monkfish: "Lotte",
halibut: "Flétan",
swordfish: "Espadon",
carp: "Carpe",
pike: "Brochet",
perch: "Perche",
tilapia: "Tilapia",
pangasius: "Panga",
smokedSalmon: "Saumon fumé",
driedFish: "Poisson séché",
eel: "Anguille",
plaice: "Carrelet (ou plie)",
saltCod: "Morue",
lemonSole: "Limande",
scorpionfish: "Rascasse",
// Shellfish
shrimp: "Crevettes",
langoustine: "Langoustines",
lobster: "Homard",
crab: "Crabe",
spinyLobster: "Langouste",
mussels: "Moules",
oysters: "Huîtres",
scallops: "Saint-Jacques",
squid: "Calamar",
octopus: "Poulpe",
clams: "Palourdes",
whelks: "Bulots",
spiderCrab: "Araignée de mer",
periwinkle: "Bigorneau",
crayfish: "Écrevisse",
greyShrimp: "Crevette grise",
cockle: "Coque",
snail: "Escargot",
cuttlefish: "Seiche",
// Starches
semolina: "Semoule",
couscous: "Couscous",
bulgur: "Boulgour",
polenta: "Polenta",
quinoa: "Quinoa",
pasta: "Pâtes",
wholeWheatPasta: "Pâtes complètes",
rice: "Riz",
basmatiRice: "Riz basmati",
brownRice: "Riz complet",
oats: "Flocons d'avoine",
spaghetti: "Spaghetti",
penne: "Penne",
tagliatelle: "Tagliatelles",
lasagnaSheets: "Lasagnes (feuilles)",
gnocchi: "Gnocchi",
arborioRice: "Riz arborio",
riceNoodles: "Nouilles de riz",
udonNoodles: "Nouilles udon",
sobaNoodles: "Nouilles soba",
chineseNoodles: "Nouilles chinoises",
riceVermicelli: "Vermicelles de riz",
soyVermicelli: "Vermicelles de soja",
stickyRice: "Riz gluant",
sushiRice: "Riz à sushi",
jasmineRice: "Riz jasmin",
// Legumes
greenLentils: "Lentilles vertes",
redLentils: "Lentilles corail",
chickpeas: "Pois chiches",
whiteBeans: "Haricots blancs",
kidneyBeans: "Haricots rouges",
blackBeans: "Haricots noirs",
splitPeas: "Pois cassés",
favaBeans: "Fèves",
edamame: "Edamame",
pintoBeans: "Haricots pinto",
flageoletBeans: "Haricots flageolets",
goldenLentils: "Lentilles blondes",
// Nuts, seeds and other dry goods
peanutsShelled: "Cacahuètes",
almonds: "Amandes",
walnuts: "Noix",
hazelnuts: "Noisettes",
cashews: "Noix de cajou",
pistachios: "Pistaches",
pecans: "Noix de pécan",
almondPowder: "Poudre d'amande",
pineNuts: "Pignons de pin",
sunflowerSeeds: "Graines de tournesol",
pumpkinSeeds: "Graines de courge",
shreddedCoconut: "Noix de coco râpée",
raisins: "Raisins secs",
prunes: "Pruneaux",
driedApricots: "Abricots secs",
sesameSeeds: "Graines de sésame",
blackMushrooms: "Champignons noirs",
noriSeaweed: "Algue nori",
wakameSeaweed: "Algue wakamé",
kombuSeaweed: "Algue kombu",
bambooShoots: "Pousses de bambou",
waterChestnuts: "Châtaignes d'eau",
// Breads
bread: "Pain",
sandwichBread: "Pain de mie",
wholeWheatBread: "Pain complet",
baguette: "Baguette",
ryeBread: "Pain de seigle",
breadcrumbs: "Chapelure",
burgerBun: "Pain à burger",
briocheBun: "Pain brioché",
hotDogBun: "Pain à hot-dog",
pitaBread: "Pain pita",
bagel: "Pain bagel",
naan: "Naan",
wrapBread: "Pain wrap",
vienneseBread: "Pain viennois",
countryBread: "Pain de campagne",
multigrainBread: "Pain aux céréales",
breadRoll: "Petit pain",
swedishBread: "Pain suédois",
glutenFreeBread: "Pain sans gluten",
rusk: "Biscotte",
croutons: "Croûtons",
focaccia: "Focaccia",
ciabatta: "Ciabatta",
cornTortilla: "Tortilla de maïs",
wheatTortilla: "Tortilla de blé",
breadstick: "Gressin",
// Raw dough
puffPastry: "Pâte feuilletée",
shortcrustPastry: "Pâte brisée",
pizzaDough: "Pâte à pizza",
sweetShortcrustPastry: "Pâte à tarte sablée",
// Dairy
milk: "Lait",
butter: "Beurre",
cremeFraiche: "Crème fraîche",
liquidCream: "Crème liquide",
cheese: "Fromage",
emmental: "Emmental",
gruyere: "Gruyère",
parmesan: "Parmesan",
mozzarella: "Mozzarella",
goatCheese: "Chèvre (fromage)",
feta: "Feta",
comte: "Comté",
fromageBlanc: "Fromage blanc",
mascarpone: "Mascarpone",
yogurt: "Yaourt",
burrata: "Burrata",
ricotta: "Ricotta",
pecorino: "Pecorino",
gorgonzola: "Gorgonzola",
cheddar: "Cheddar",
brie: "Brie",
camembert: "Camembert",
roquefort: "Roquefort",
munster: "Munster",
reblochon: "Reblochon",
cantal: "Cantal",
beaufort: "Beaufort",
saintNectaire: "Saint-Nectaire",
blueCheese: "Bleu (fromage)",
cancoillotte: "Cancoillotte",
tomme: "Tomme",
epoisses: "Époisses",
chaource: "Chaource",
livarot: "Livarot",
pontLeveque: "Pont-l'Évêque",
morbier: "Morbier",
racletteCheese: "Raclette (fromage)",
fourmeDAmbert: "Fourme d'Ambert",
salers: "Salers",
ossauIraty: "Ossau-Iraty",
vacherin: "Vacherin",
saintMarcellin: "Saint-Marcellin",
neufchatel: "Neufchâtel",
crottinDeChavignol: "Crottin de Chavignol",
abondanceCheese: "Abondance",
carreDeLEst: "Carré de l'Est",
edam: "Edam",
gouda: "Gouda",
mimolette: "Mimolette",
maroilles: "Maroilles",
montDor: "Mont d'or",
kefir: "Kéfir",
greekYogurt: "Yaourt à la grecque",
// Eggs
egg: "Oeuf",
eggYolk: "Jaune d'oeuf",
eggWhite: "Blanc d'oeuf",
// Plant-based alternatives
coconutMilk: "Lait de coco",
coconutCream: "Crème de coco",
almondMilk: "Lait d'amande",
oatMilk: "Lait d'avoine",
tofu: "Tofu",
silkenTofu: "Tofu soyeux",
// Spices
herbesDeProvence: "Herbes de Provence",
blackPepper: "Poivre noir",
paprika: "Paprika",
espelettePepper: "Piment d'Espelette",
cayennePepper: "Piment de Cayenne",
cumin: "Cumin",
curryPowder: "Curry (poudre)",
turmeric: "Curcuma",
cinnamon: "Cannelle",
nutmeg: "Muscade",
saffron: "Safran",
clove: "Clou de girofle",
vanillaBean: "Vanille (gousse)",
whitePepper: "Poivre blanc",
pinkPepper: "Poivre rose",
sichuanPepper: "Poivre du Sichuan",
smokedPaprika: "Paprika fumé",
birdEyeChili: "Piment oiseau",
juniperBerries: "Baies de genièvre",
starAnise: "Anis étoilé (badiane)",
greenAnise: "Anis vert",
fennelSeeds: "Graines de fenouil",
sumac: "Sumac",
nigella: "Nigelle",
allspice: "Quatre épices",
colomboPowder: "Colombo (poudre)",
baharat: "Baharat",
horseradish: "Raifort",
herbSalt: "Sel aux herbes",
celerySalt: "Sel de céleri",
fleurDeSel: "Fleur de sel",
salt: "Sel",
fiveSpice: "Cinq épices",
garamMasala: "Garam masala",
corianderSeeds: "Graines de coriandre",
groundCoriander: "Coriandre en poudre",
cardamom: "Cardamome",
fenugreek: "Fenugrec",
jalapeno: "Piment jalapeño",
chipotle: "Piment chipotle",
poblanoPepper: "Piment poblano",
habanero: "Piment habanero",
rasElHanout: "Ras el hanout",
zaatar: "Za'atar",
// Sauces
soySauce: "Sauce soja",
mustard: "Moutarde",
mayonnaise: "Mayonnaise",
ketchup: "Ketchup",
tabasco: "Tabasco",
worcestershireSauce: "Sauce Worcestershire",
fishSauce: "Sauce nuoc-mâm",
wasabi: "Wasabi",
harissa: "Harissa",
curryPaste: "Pâte de curry",
peanutButter: "Beurre de cacahuète",
dijonMustard: "Moutarde de Dijon",
wholegrainMustard: "Moutarde à l'ancienne",
barbecueSauce: "Sauce barbecue",
tartarSauce: "Sauce tartare",
cocktailSauce: "Sauce cocktail",
bearnaiseSauce: "Sauce béarnaise",
hollandaiseSauce: "Sauce hollandaise",
bechamelSauce: "Sauce béchamel",
teriyakiSauce: "Sauce teriyaki",
ponzuSauce: "Sauce ponzu",
chimichurri: "Chimichurri",
redPesto: "Pesto rouge (tomates séchées)",
pesto: "Pesto",
oysterSauce: "Sauce huître",
hoisinSauce: "Sauce hoisin",
sriracha: "Sauce sriracha",
sweetChiliSauce: "Sauce sweet chili",
miso: "Miso",
shrimpPaste: "Pâte de crevettes",
redCurryPaste: "Pâte de curry rouge (thaï)",
greenCurryPaste: "Pâte de curry vert (thaï)",
tahini: "Tahini",
aioli: "Aïoli",
vinaigrette: "Sauce vinaigrette",
hummus: "Houmous",
// Seasonings — oils, vinegars, wines and other flavorings
oliveOil: "Huile d'olive",
sunflowerOil: "Huile de tournesol",
rapeseedOil: "Huile de colza",
coconutOil: "Huile de coco",
sesameOil: "Huile de sésame",
ciderVinegar: "Vinaigre de cidre",
whiteVinegar: "Vinaigre blanc",
balsamicVinegar: "Vinaigre balsamique",
capers: "Câpres",
olives: "Olives",
blackOlives: "Olives noires",
greenOlives: "Olives vertes",
whiteWine: "Vin blanc (cuisine)",
redWine: "Vin rouge (cuisine)",
roseWine: "Vin rosé (cuisine)",
redWineVinegar: "Vinaigre de vin rouge",
whiteWineVinegar: "Vinaigre de vin blanc",
sherryVinegar: "Vinaigre de xérès",
walnutOil: "Huile de noix",
hazelnutOil: "Huile de noisette",
peanutOil: "Huile d'arachide",
chiliOil: "Huile pimentée",
riceVinegar: "Vinaigre de riz",
cornOil: "Huile de maïs",
grapeseedOil: "Huile de pépins de raisin",
soybeanOil: "Huile de soja",
palmOil: "Huile de palme",
mirin: "Mirin",
sake: "Saké (cuisine)",
lemonJuice: "Jus de citron",
limeJuice: "Jus de citron vert",
orangeJuice: "Jus d'orange",
appleJuice: "Jus de pomme",
grapeJuice: "Jus de raisin",
tomatoJuice: "Jus de tomate",
cranberryJuice: "Jus de cranberry",
coffee: "Café",
tea: "Thé",
beer: "Bière (cuisine)",
cider: "Cidre (cuisine)",
champagne: "Champagne / vin pétillant (cuisine)",
portWine: "Porto (cuisine)",
vinJaune: "Vin jaune (cuisine)",
cognac: "Cognac",
rum: "Rhum",
whisky: "Whisky",
vodka: "Vodka",
// Bases — flours, stocks and other cooking essentials
wheatFlour: "Farine de blé",
wholeWheatFlour: "Farine complète",
cornFlour: "Farine de maïs",
buckwheatFlour: "Farine de sarrasin",
riceFlour: "Farine de riz",
vegetableStockCube: "Bouillon cube légumes",
chickenStockCube: "Bouillon cube volaille",
tomatoPaste: "Concentré de tomate",
tomatoCoulis: "Coulis de tomate",
cannedPeeledTomatoes: "Tomates pelées (conserve)",
sunDriedTomatoes: "Tomates séchées",
vealStock: "Fond de veau",
chickenStock: "Fond de volaille",
beefStockCube: "Bouillon cube bœuf",
fishStockCube: "Bouillon cube poisson",
vegetableBroth: "Bouillon de légumes",
chickenBroth: "Bouillon de volaille",
beefBroth: "Bouillon de bœuf",
courtBouillon: "Court-bouillon",
dashi: "Dashi (bouillon japonais)",
shellfishBisque: "Bisque de crustacés",
tapiocaFlour: "Farine de tapioca",
masaHarina: "Masa harina",
water: "Eau",
sparklingWater: "Eau gazeuse",
orangeBlossomWater: "Eau de fleur d'oranger",
roseWater: "Eau de rose",
fishFumet: "Fumet de poisson",
// Thickeners and raising agents
bakersYeast: "Levure boulangère",
bakingPowder: "Levure chimique",
cornstarch: "Maïzena",
lupinFlour: "Farine de lupin",
gelatin: "Gélatine",
bakingSoda: "Bicarbonate de soude",
potatoStarch: "Fécule de pomme de terre",
// Sugars
sugar: "Sucre",
honey: "Miel",
mapleSyrup: "Sirop d'érable",
brownSugar: "Sucre roux",
powderedSugar: "Sucre glace",
demeraraSugar: "Cassonade",
darkChocolate: "Chocolat noir",
milkChocolate: "Chocolat au lait",
whiteChocolate: "Chocolat blanc",
chocolateChips: "Pépites de chocolat",
cocoaPowder: "Cacao en poudre",
vanillaExtract: "Extrait de vanille",
palmSugar: "Sucre de palme",
caneSyrup: "Sirop de sucre de canne",
};
/**
* Extra French matching phrases for a handful of {@link INGREDIENT_LABELS_FR}
* entries whose primary (display) label doesn't match how the phrase is
* actually written in running recipe text see that constant's own doc
* comment for why this is expected to grow over time, the same "exceptions
* only, not every entry" shape as {@link INGREDIENT_LABEL_SYNONYMS_EN}.
*/
export const INGREDIENT_LABEL_SYNONYMS_FR: Record<string, string[]> = {
// "Vanille (gousse)" is display-first-word-last; real recipe text says
// "gousse de vanille" (pod word first) — see INGREDIENT_LABELS_FR's doc
// comment.
vanillaBean: ["Gousse de vanille"],
};
/**
* French matching synonyms for the `Unit` reference catalog
* (`apps/api/src/db/reference-seed-data.ts`'s `UNITS`), keyed by
* `Unit.key` the French counterpart to {@link UNIT_LABELS_EN}. Unlike
* {@link INGREDIENT_LABELS_FR}, these are **not** copied from
* `apps/web`'s locale file: that file has exactly one display label per
* unit (`apps/web/src/locales/fr/translation.json`'s `catalog.units`,
* e.g. `tablespoon`: "cuillère à soupe"), fine for a dropdown but not
* enough to *match* French recipe text against real recipes freely mix
* the full phrase, its plural, and common abbreviations ("cuillère à
* soupe", "cuillères à soupe", "c. à soupe", "càs" all appear in the
* wild), so each entry here is hand-authored the same way
* {@link UNIT_LABELS_EN} was, starting from that same display label.
*
* Several of these are genuinely multi-word ("cuillère à soupe") unlike
* English units, which are always a single word/abbreviation. Matching a
* multi-word unit needs the same ordered-contiguous-run search
* `matchIngredientName` already does for ingredients, not the older
* single-first-word check `matchUnit` used before French support existed
* see `matchUnit`'s own doc comment (`ingredient-matcher.ts`) for the
* bug that would otherwise cause: a multi-word French synonym's *whole
* phrase* (spaces and all) would never equal a single extracted word, so
* it could never match anything at all.
*/
export const UNIT_LABELS_FR: Record<string, string[]> = {
gram: ["g", "gr", "gramme", "grammes"],
kilogram: ["kg", "kilo", "kilos", "kilogramme", "kilogrammes"],
milliliter: ["ml", "millilitre", "millilitres"],
centiliter: ["cl", "centilitre", "centilitres"],
liter: ["l", "litre", "litres"],
tablespoon: ["cuillère à soupe", "cuillères à soupe", "c. à soupe", "c à soupe", "cas", "càs"],
teaspoon: ["cuillère à café", "cuillères à café", "c. à café", "c à café", "cac", "càc"],
piece: ["unité", "unités", "pièce", "pièces"],
pinch: ["pincée", "pincées"],
slice: ["tranche", "tranches"],
clove: ["gousse", "gousses"],
bunch: ["botte", "bottes"],
sachet: ["sachet", "sachets"],
sprig: ["brin", "brins"],
cup: ["tasse", "tasses"],
ounce: ["once", "onces"],
pound: ["livre", "livres"],
};

View file

@ -64,6 +64,14 @@ export enum ErrorCode {
UNIT_NOT_FOUND = 4048, UNIT_NOT_FOUND = 4048,
/** `PATCH /house/current/sources`'s `sourceIds` contains one that doesn't match any reference `Source` row. */ /** `PATCH /house/current/sources`'s `sourceIds` contains one that doesn't match any reference `Source` row. */
SOURCE_NOT_FOUND = 4049, SOURCE_NOT_FOUND = 4049,
/** `POST /recipes/:id/steps/:stepId/corrections` given a `stepId` that doesn't belong to a recipe visible to the caller. */
STEP_NOT_FOUND = 4050,
/** A tech-step correction's `previousTechStepId`/`correctedTechStepId` doesn't match any reference `TechStep` row. */
TECH_STEP_NOT_FOUND = 4051,
/** A tech-step correction's manually-attached `utensils[].utensilId` doesn't match any reference `Utensil` row. */
UTENSIL_NOT_FOUND = 4052,
/** A tech-step correction's `start`/`end` span falls outside the target step's `description`, or `start >= end`. */
INVALID_CORRECTION_SPAN = 4002,
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */ /** Unexpected/unhandled failure — the catch-all, always logged server-side. */
INTERNAL_ERROR = 5000, INTERNAL_ERROR = 5000,
} }

View file

@ -4,6 +4,7 @@
// detail specific to one side. // detail specific to one side.
export * from "./data/catalog-labels-en.js"; export * from "./data/catalog-labels-en.js";
export * from "./data/catalog-labels-fr.js";
export * from "./errors/error-codes.js"; export * from "./errors/error-codes.js";
export * from "./schemas/account.js"; export * from "./schemas/account.js";
export * from "./schemas/auth.js"; export * from "./schemas/auth.js";
@ -12,12 +13,16 @@ export * from "./schemas/planning.js";
export * from "./schemas/preferences.js"; export * from "./schemas/preferences.js";
export * from "./schemas/profile.js"; export * from "./schemas/profile.js";
export * from "./schemas/recipe.js"; export * from "./schemas/recipe.js";
export * from "./schemas/shopping-list.js";
export * from "./schemas/sources.js"; export * from "./schemas/sources.js";
export * from "./schemas/tech-step-worker.js";
export * from "./tools/assert-is-never.js"; export * from "./tools/assert-is-never.js";
export * from "./types/household.js"; export * from "./types/household.js";
export * from "./types/planning.js"; export * from "./types/planning.js";
export * from "./types/preferences.js"; export * from "./types/preferences.js";
export * from "./types/recipe.js"; export * from "./types/recipe.js";
export * from "./types/reference.js"; export * from "./types/reference.js";
export * from "./types/shopping-list.js";
export * from "./types/sources.js"; export * from "./types/sources.js";
export * from "./types/tech-step-worker.js";
export * from "./types/user-profile.js"; export * from "./types/user-profile.js";

View file

@ -116,3 +116,97 @@ export const listRecipesSchema = z.object({
}); });
/** Inferred TS type for {@link listRecipesSchema}'s validated output. */ /** Inferred TS type for {@link listRecipesSchema}'s validated output. */
export type ListRecipesInput = z.infer<typeof listRecipesSchema>; export type ListRecipesInput = z.infer<typeof listRecipesSchema>;
/**
* One ingredient mention the user themselves points at while correcting a
* technique `start`/`end` is *their own* selection of the exact passage
* of `description` that names it (a separate selection from the
* correction's own `[start, end)`, see `TechStepCorrectionPopover.tsx`),
* not derived from anything the classifier found. `quantity`/`unitId`
* are optional a mention with no quantity attached ("ajouter le sel")
* is still worth recording. See `submitTechStepCorrectionSchema`'s own
* doc comment for how `ingredients` as a whole behaves.
*/
const manualStepTechStepIngredientInputSchema = z
.object({
ingredientId: z.number().int().positive(),
quantity: z.number().positive("La quantité doit être positive").nullable().optional(),
unitId: z.number().int().positive().nullable().optional(),
start: z.number().int().nonnegative(),
end: z.number().int().nonnegative(),
})
.refine((ingredient) => ingredient.end > ingredient.start, {
message: "end must be greater than start",
path: ["end"],
});
/** A utensil mention the user points at while correcting a technique — same `start`/`end` convention as {@link manualStepTechStepIngredientInputSchema}, no quantity/unit (nothing to measure for a utensil). */
const manualStepTechStepUtensilInputSchema = z
.object({
utensilId: z.number().int().positive(),
start: z.number().int().nonnegative(),
end: z.number().int().nonnegative(),
})
.refine((utensil) => utensil.end > utensil.start, {
message: "end must be greater than start",
path: ["end"],
});
/**
* Payload accepted by `POST /recipes/:id/steps/:stepId/corrections` a
* user asserting what technique a `[start, end)` span of a step's
* `description` should (or shouldn't) be tagged with. `previousTechStepId`
* is the existing match being corrected (omit/`null` when the user is
* flagging a technique the classifier missed entirely nothing to
* correct, just to add); `correctedTechStepId` is what they assert instead
* (omit/`null` means "no technique belongs here", i.e. removing a wrong
* match). Rejecting both being absent at once happens service-side
* (`recipe-tech-step-correction.service.ts`) needs the target step's
* `description` length to validate `start`/`end` against, which this shape
* alone can't see.
*
* `ingredients`/`utensils` let the user attach metadata to the technique
* they're asserting (`correctedTechStepId`), same `source: "manual"`
* distinction the technique itself gets. **Omitted (`undefined`) means
* "leave whatever metadata already exists on this occurrence alone"
* an explicit array, even `[]`, means "this is now the complete set,
* replace everything that was there" (auto-detected included; see
* `applyManualCorrection`'s own doc comment). This is why neither field
* has a `.default([])`: that would silently turn every plain relabel into
* a metadata wipe.** Only meaningful alongside a real `correctedTechStepId`
* enforced by this schema's own refine below, since there's no live
* `StepTechStep` row to attach to otherwise (removing a match, or a
* request with neither id set).
*/
export const submitTechStepCorrectionSchema = z
.object({
start: z.number().int().nonnegative(),
end: z.number().int().nonnegative(),
previousTechStepId: z.number().int().positive().nullable().optional(),
correctedTechStepId: z.number().int().positive().nullable().optional(),
ingredients: z.array(manualStepTechStepIngredientInputSchema).optional(),
utensils: z.array(manualStepTechStepUtensilInputSchema).optional(),
})
.refine((input) => input.end > input.start, {
message: "end must be greater than start",
path: ["end"],
})
.refine(
(input) =>
(input.previousTechStepId ?? null) !== null || (input.correctedTechStepId ?? null) !== null,
{
message: "at least one of previousTechStepId/correctedTechStepId is required",
path: ["correctedTechStepId"],
},
)
.refine(
(input) =>
(input.ingredients === undefined && input.utensils === undefined) ||
(input.correctedTechStepId ?? null) !== null,
{
message: "ingredients/utensils require a correctedTechStepId to attach to",
path: ["correctedTechStepId"],
},
);
/** Inferred TS type for {@link submitTechStepCorrectionSchema}'s validated output. */
export type SubmitTechStepCorrectionInput = z.infer<typeof submitTechStepCorrectionSchema>;

View file

@ -0,0 +1,19 @@
import { z } from "zod";
// See schemas/auth.ts for the shared client/server validation rationale.
/**
* Payload accepted by `GET /shopping-list`'s `?date=` query param same
* shape/rationale as `schemas/planning.ts`'s `getPlanningByDateSchema`
* (only checks the `YYYY-MM-DD` shape, real-calendar-date validation is
* service-side via `@batch-cooking/date-tools`'s `parseDateOnly`). Kept as
* its own schema rather than importing `getPlanningByDateSchema` each
* router module owns its own request contract in this repo, even when two
* happen to share a shape (see the two near-identical `date` fields already
* inside `schemas/planning.ts` itself).
*/
export const getShoppingListSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date invalide"),
});
/** Inferred TS type for {@link getShoppingListSchema}'s validated output. */
export type GetShoppingListInput = z.infer<typeof getShoppingListSchema>;

View file

@ -0,0 +1,54 @@
import { z } from "zod";
/** Query params accepted by `GET /internal/tech-steps/audit-batch` and `GET /internal/tech-steps/pending-corrections` — both just a bound on how much work one call asks for, so the worker controls its own batch size rather than the server guessing. */
export const workerBatchQuerySchema = z.object({
limit: z.coerce.number().int().positive().max(500).default(50),
});
/** Inferred TS type for {@link workerBatchQuerySchema}'s validated output. */
export type WorkerBatchQueryInput = z.infer<typeof workerBatchQuerySchema>;
/** `locale` param `GET /internal/tech-steps/audit-batch` also accepts, on top of {@link workerBatchQuerySchema} — which of `TECH_STEP_TRAINING_DATA`'s locales to sample steps' clauses against (see `tech-step-matcher.ts`'s `matchTechStepSpans` for the same parameter on the read side). No closed enum here (unlike `recipeVisibilitySchema`) — the training data's own locale list can grow without a schema change. */
export const auditBatchQuerySchema = workerBatchQuerySchema.extend({
locale: z.string().min(2).default("fr"),
});
/** Inferred TS type for {@link auditBatchQuerySchema}'s validated output. */
export type AuditBatchQueryInput = z.infer<typeof auditBatchQuerySchema>;
/**
* One suggestion in the batch `POST /internal/tech-steps/training-suggestions`
* accepts `techStepKey` (not an id) since the worker never has direct DB
* access to resolve one itself; the API resolves it, and rejects the whole
* batch with `TECH_STEP_NOT_FOUND` if any key is unknown (see
* `tech-step-worker.service.ts`). `sourceCorrectionId` is required when
* `sourceType` is `"correction"` (that's the whole point of that source
* it exists *because of* one specific correction) and must be absent
* otherwise enforced by the refinement below, not by two separate
* schemas, so the error message can point at exactly which field is wrong.
*/
const trainingSuggestionSchema = z
.object({
techStepKey: z.string().min(1),
locale: z.string().min(2),
suggestedSynonyms: z.array(z.string().trim().min(1)),
suggestedUtterances: z.array(z.string().trim().min(1)),
sourceType: z.enum(["correction", "llm_audit"]),
sourceCorrectionId: z.number().int().positive().nullable().optional(),
})
.refine(
(input) =>
input.sourceType === "correction"
? input.sourceCorrectionId !== null && input.sourceCorrectionId !== undefined
: input.sourceCorrectionId === null || input.sourceCorrectionId === undefined,
{
message:
"sourceCorrectionId is required when sourceType is 'correction', and must be absent otherwise",
path: ["sourceCorrectionId"],
},
);
/** Payload accepted by `POST /internal/tech-steps/training-suggestions` — a batch, not one suggestion per call, since the worker's audit/correction jobs naturally produce several at once per run and there's no reason to round-trip once per suggestion. */
export const submitTrainingSuggestionsSchema = z.object({
suggestions: z.array(trainingSuggestionSchema).min(1),
});
/** Inferred TS type for {@link submitTrainingSuggestionsSchema}'s validated output. */
export type SubmitTrainingSuggestionsInput = z.infer<typeof submitTrainingSuggestionsSchema>;

View file

@ -1,4 +1,11 @@
import type { AllergyView, DietView, IngredientView, TechStepView, UnitView } from "./reference.js"; import type {
AllergyView,
DietView,
IngredientView,
TechStepView,
UnitView,
UtensilView,
} from "./reference.js";
/** /**
* Who can *read* a recipe mirrors `RecipeVisibility` in schema.prisma. * Who can *read* a recipe mirrors `RecipeVisibility` in schema.prisma.
@ -43,6 +50,17 @@ export interface RecipeIngredientView {
* and not yet recomputed (see `StepTechStep`'s schema doc comment) a * and not yet recomputed (see `StepTechStep`'s schema doc comment) a
* caller with no context just shows the keyword highlight alone, same as * caller with no context just shows the keyword highlight alone, same as
* before these existed. * before these existed.
*
* `source` mirrors `StepTechStep.source` (schema.prisma) `"auto"` is the
* classifier's own detection, `"manual"` is a viewer's correction applied
* immediately (`recipe-tech-step-correction.service.ts`'s
* `applyManualCorrection`). `StepDescription.tsx` renders the two with a
* different highlight color so a viewer can tell which is which.
*
* `ingredients`/`utensils` are the metadata found in this technique's own
* clause (see `tech-step-matcher.ts`'s `TechStepMatch` same source data,
* just resolved to full reference views here instead of bare ids) `[]`
* when nothing was mentioned alongside this technique.
*/ */
export interface StepTechStepView { export interface StepTechStepView {
techStep: TechStepView; techStep: TechStepView;
@ -50,6 +68,39 @@ export interface StepTechStepView {
end: number; end: number;
contextStart?: number; contextStart?: number;
contextEnd?: number; contextEnd?: number;
source: "auto" | "manual";
ingredients: StepTechStepIngredientView[];
utensils: StepTechStepUtensilView[];
}
/**
* An ingredient mentioned in the same clause as a detected technique (see
* {@link StepTechStepView.ingredients}) `quantity`/`unit` are `null` when
* none was recognized immediately before the mention (e.g. "ajouter le
* sel"), same "best-effort, not always present" contract as
* `tech-step-matcher.ts`'s `IngredientMention`. `start`/`end` are the
* mention's own span in the step's `description`, same `[start, end)`
* convention as {@link StepTechStepView.start}.
*
* `source` mirrors {@link StepTechStepView.source} `"auto"` is the
* classifier's own detection, `"manual"` is a viewer's own selection
* (`SubmitTechStepCorrectionInput.ingredients`, `TechStepCorrectionPopover.tsx`).
*/
export interface StepTechStepIngredientView {
ingredient: IngredientView;
quantity: number | null;
unit: UnitView | null;
start: number;
end: number;
source: "auto" | "manual";
}
/** A utensil mentioned in the same clause as a detected technique (see {@link StepTechStepView.utensils}) — `source` mirrors {@link StepTechStepIngredientView.source}. */
export interface StepTechStepUtensilView {
utensil: UtensilView;
start: number;
end: number;
source: "auto" | "manual";
} }
/** /**
@ -99,3 +150,42 @@ export interface RecipeView extends RecipeSummaryView {
ingredients: RecipeIngredientView[]; ingredients: RecipeIngredientView[];
steps: StepView[]; steps: StepView[];
} }
/**
* One user-submitted correction to a step's detected techniques, as
* returned by `GET /recipes/:id/steps/:stepId/corrections` the read side
* of `POST` on the same route (`submitTechStepCorrectionSchema`,
* `packages/shared/src/schemas/recipe.ts`). `previousTechStep`/
* `correctedTechStep` are resolved to their reference data (same "resolve
* at read time" treatment as {@link StepTechStepView.techStep}) rather than
* bare ids `null` carries the same "missing"/"none" meaning documented on
* `StepTechStepCorrection` in schema.prisma. Not shown to *every* viewer of
* a step by default in `apps/web` today (see `StepDescription.tsx`)
* mainly useful for a user checking what they (or others) already
* submitted before adding another correction to the same span.
*/
export interface StepTechStepCorrectionView {
id: number;
start: number;
end: number;
previousTechStep: TechStepView | null;
correctedTechStep: TechStepView | null;
createdAt: string;
}
/**
* Response of `POST /recipes/:id/steps/:stepId/corrections` the audit
* record just created, plus the step's fresh, immediately up-to-date
* `techSteps` sequence (`StepView.techSteps`'s own shape) after applying
* it. `apps/web`'s `StepDescription.tsx` replaces its local copy of the
* step's `techSteps` with this on a successful submit, so the new/relabeled
* `"manual"`-sourced highlight appears right away the API is the single
* source of truth for exactly which entry changed and how (a relabel
* updates one row in place, an add inserts one, a remove deletes one; see
* `recipe-tech-step-correction.service.ts`'s `applyManualCorrection`), so
* the frontend never tries to replicate that logic client-side.
*/
export interface SubmitTechStepCorrectionResult {
correction: StepTechStepCorrectionView;
techSteps: StepTechStepView[];
}

Some files were not shown because too many files have changed in this diff Show more