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> = { en: INGREDIENT_LABELS_EN, fr: INGREDIENT_LABELS_FR, }; const INGREDIENT_LABEL_SYNONYMS_BY_LOCALE: Record> = { en: INGREDIENT_LABEL_SYNONYMS_EN, fr: INGREDIENT_LABEL_SYNONYMS_FR, }; const UNIT_LABELS_BY_LOCALE: Record> = { 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 { try { const labels = INGREDIENT_LABELS_BY_LOCALE[locale] ?? {}; const synonyms = INGREDIENT_LABEL_SYNONYMS_BY_LOCALE[locale] ?? {}; const ingredients = await prisma.ingredient.findMany({ // Placeholder rows (user free-text, `placeholder:` key) have no // authored label so they'd be skipped by the `label === undefined` // check below anyway — filtered here too so an import never even // considers resolving one raw line to another line's placeholder. where: { isPlaceholder: false }, 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 { 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 } }