* 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>
466 lines
22 KiB
TypeScript
466 lines
22 KiB
TypeScript
import {
|
||
INGREDIENT_LABEL_SYNONYMS_EN,
|
||
INGREDIENT_LABEL_SYNONYMS_FR,
|
||
INGREDIENT_LABELS_EN,
|
||
INGREDIENT_LABELS_FR,
|
||
UNIT_LABELS_EN,
|
||
UNIT_LABELS_FR,
|
||
} from "@batch-cooking/shared";
|
||
import { prisma } from "../../db/prisma.js";
|
||
import { normalizeText } from "./tech-step-matcher.js";
|
||
|
||
/**
|
||
* Resolves the free-text `name`/`unit`/`quantity` a `RecipeSourceAdapter`
|
||
* lifts from a recipe source (`ParsedRecipeIngredient`) against our own
|
||
* `Ingredient`/`Unit` reference catalogs — the ingredient-side counterpart
|
||
* to `tech-step-matcher.ts`'s technique detection, built for the same
|
||
* reason: a source's raw text has no idea our catalogs even exist.
|
||
*
|
||
* Unlike tech steps (regex mappings hand-authored per technique),
|
||
* ingredient/unit labels are plain hand-written text, one table per
|
||
* supported `locale` (`INGREDIENT_LABELS_EN`/`INGREDIENT_LABELS_FR`/
|
||
* `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,
|
||
* lighter algorithm: word-tokenize both sides, naively stem for plurals,
|
||
* then look for the catalog phrase's tokens as a contiguous run inside the
|
||
* ingredient text's tokens. The longest (most specific) matching catalog
|
||
* phrase wins, same "specificity resolves overlaps" idea as
|
||
* `matchTechSteps`, just without weights (there's no need to rank two
|
||
* *unrelated* ingredients — only a phrase against its own substrings, e.g.
|
||
* "chicken breast" beating bare "chicken").
|
||
*
|
||
* `matchIngredientName`/`matchUnit`/`extractQuantity` are pure, so they're
|
||
* unit-testable without a database (see `test/ingredient-matcher.test.ts`);
|
||
* `loadIngredientCatalog`/`loadUnitCatalog` are the only DB-touching pieces,
|
||
* meant to be fetched once per request and reused across every ingredient
|
||
* line, the same "don't requery per item" convention
|
||
* `tech-step-matcher.ts`'s `TechStepClassifierService` follows for its own
|
||
* one-time training pass.
|
||
*/
|
||
|
||
/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its matching label in whichever locale it was loaded for. */
|
||
export interface IngredientMatchEntry {
|
||
ingredientId: number;
|
||
/** 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;
|
||
}
|
||
|
||
/** One `Unit` row trimmed to what {@link matchUnit} needs, alongside its accepted spellings in whichever locale it was loaded for. */
|
||
export interface UnitMatchEntry {
|
||
unitId: number;
|
||
/** 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[];
|
||
}
|
||
|
||
/**
|
||
* Naive English stemmer — strips a plural suffix so "tomato"/"tomatoes",
|
||
* "onion"/"onions", "cherry"/"cherries" compare equal after stemming.
|
||
* Deliberately not a real linguistic stemmer: it only has to be
|
||
* *consistent* (the same word always stems the same way) since both sides
|
||
* of every comparison go through it, not linguistically correct on its
|
||
* own — see the module doc comment.
|
||
*/
|
||
function stemWordEn(word: string): string {
|
||
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("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1);
|
||
return word;
|
||
}
|
||
|
||
/**
|
||
* 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)
|
||
.split(/[^a-z]+/)
|
||
.filter((word) => word.length > 0)
|
||
.map((word) => stemWord(word, locale));
|
||
}
|
||
|
||
/** Whether `needle` appears as a contiguous run inside `haystack`, at any starting position. */
|
||
function containsSubsequence(haystack: string[], needle: string[]): boolean {
|
||
if (needle.length === 0 || needle.length > haystack.length) return false;
|
||
for (let start = 0; start <= haystack.length - needle.length; start++) {
|
||
if (needle.every((word, i) => haystack[start + i] === word)) return true;
|
||
}
|
||
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
|
||
* diced yellow onions"`) to the best-matching `Ingredient` in `catalog`, or
|
||
* `null` if nothing matches. Among every catalog entry whose label's words
|
||
* all appear as a contiguous run in `name`, **in the same order**, the one
|
||
* with the most words wins (most specific — "chicken breast" over bare
|
||
* "chicken"); ties break on the lowest `ingredientId`, for a deterministic
|
||
* 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[],
|
||
locale = "en",
|
||
): number | null {
|
||
const nameTokens = tokenize(name, locale);
|
||
if (nameTokens.length === 0) return null;
|
||
|
||
let best: { ingredientId: number; tokenCount: number } | null = null;
|
||
for (const entry of catalog) {
|
||
const labelTokens = tokenize(entry.label, locale);
|
||
if (!containsSubsequence(nameTokens, labelTokens)) continue;
|
||
if (
|
||
best === null ||
|
||
labelTokens.length > best.tokenCount ||
|
||
(labelTokens.length === best.tokenCount && entry.ingredientId < best.ingredientId)
|
||
) {
|
||
best = {
|
||
ingredientId: entry.ingredientId,
|
||
tokenCount: labelTokens.length,
|
||
};
|
||
}
|
||
}
|
||
return best?.ingredientId ?? null;
|
||
}
|
||
|
||
/**
|
||
* Resolves free-text `unitText` (e.g. `"tbsp"`, `"Cups"`, `"cuillères à
|
||
* soupe de farine"`) to the best-matching `Unit` in `catalog`, or `null` if
|
||
* nothing matches. Same ordered-contiguous-run search as
|
||
* {@link matchIngredientName}, longest match wins — **not** the
|
||
* single-first-word equality check this function used before French
|
||
* 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[],
|
||
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 synonym of entry.synonyms) {
|
||
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 best?.unitId ?? null;
|
||
}
|
||
|
||
/** What {@link extractQuantity} pulls out of a leading numeric expression, alongside what's left of the string after it. */
|
||
export interface ExtractedQuantity {
|
||
quantity: number | null;
|
||
/** `rawText` with the leading quantity (and any separating whitespace) removed — `rawText` unchanged if none was found. */
|
||
remainder: string;
|
||
}
|
||
|
||
// 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 —
|
||
// 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+)?)/;
|
||
|
||
/**
|
||
* Pulls a leading quantity off `rawText` (e.g. `"1 1/2 cups flour"` ->
|
||
* `{ quantity: 1.5, remainder: "cups flour" }`), supporting a plain
|
||
* integer/decimal, a simple fraction (`"1/2"`), or a mixed number
|
||
* (`"1 1/2"`). `quantity: null` (remainder === rawText, trimmed) when
|
||
* `rawText` doesn't start with a recognizable number — e.g. `"salt to
|
||
* taste"`, which has none.
|
||
*/
|
||
export function extractQuantity(rawText: string): ExtractedQuantity {
|
||
const trimmed = rawText.trim();
|
||
const match = LEADING_QUANTITY_PATTERN.exec(trimmed);
|
||
if (!match) return { quantity: null, remainder: trimmed };
|
||
|
||
let quantity: number;
|
||
if (match[1] !== undefined) {
|
||
// Mixed number: "1 1/2".
|
||
quantity = Number(match[1]) + Number(match[2]) / Number(match[3]);
|
||
} else if (match[4] !== undefined) {
|
||
// Simple fraction: "1/2".
|
||
quantity = Number(match[4]) / Number(match[5]);
|
||
} else {
|
||
// Plain integer/decimal: "2" or "1.5"/"1,5" — group 6 is guaranteed
|
||
// defined here (the only remaining alternative in the pattern), `?? ""`
|
||
// is just satisfying the indexed-access type, not a real fallback.
|
||
quantity = Number((match[6] ?? "").replace(",", "."));
|
||
}
|
||
|
||
return { quantity, remainder: trimmed.slice(match[0].length).trim() };
|
||
}
|
||
|
||
/**
|
||
* 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 {
|
||
const labels = INGREDIENT_LABELS_BY_LOCALE[locale] ?? {};
|
||
const synonyms = INGREDIENT_LABEL_SYNONYMS_BY_LOCALE[locale] ?? {};
|
||
const ingredients = await prisma.ingredient.findMany({
|
||
select: { id: true, key: true },
|
||
});
|
||
const catalog: IngredientMatchEntry[] = [];
|
||
for (const ingredient of ingredients) {
|
||
const label = labels[ingredient.key];
|
||
if (label === undefined) continue;
|
||
catalog.push({ ingredientId: ingredient.id, label });
|
||
for (const synonym of synonyms[ingredient.key] ?? []) {
|
||
catalog.push({ ingredientId: ingredient.id, label: synonym });
|
||
}
|
||
}
|
||
return catalog;
|
||
} catch (err) {
|
||
// Rethrown as-is — the caller (`sources.service.ts`/`recipe-translation.ts`)
|
||
// already handles/logs failures centrally; this function just isn't
|
||
// allowed a bare `await` per the repo's async/try-catch convention.
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/** 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(locale = "en"): Promise<UnitMatchEntry[]> {
|
||
try {
|
||
const labels = UNIT_LABELS_BY_LOCALE[locale] ?? {};
|
||
const units = await prisma.unit.findMany({
|
||
select: { id: true, key: true },
|
||
});
|
||
const catalog: UnitMatchEntry[] = [];
|
||
for (const unit of units) {
|
||
const synonyms = labels[unit.key];
|
||
if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms });
|
||
}
|
||
return catalog;
|
||
} catch (err) {
|
||
throw err; // see loadIngredientCatalog()'s catch comment above
|
||
}
|
||
}
|