import { prisma } from "../../db/prisma.js"; /** * Auto-detects which cooking techniques (`TechStep`) a free-text recipe * step description corresponds to, using the static `TechStepMapping` * catalog (see `reference-seed-data.ts`'s `TECH_STEPS`) — groundwork for a * future batch-cooking optimization algorithm, and (via `matchTechStepSpans`) * what `recipe.service.ts` persists as `StepTechStep.start`/`end` so the * recipe UI can highlight the exact matched words (see `StepView` in * `packages/shared`). * * A single instruction can genuinely involve more than one technique (e.g. * "Dans une poêle chaude, faire chauffer une noix de beurre" is both * `preheat` and `melt`) — both `matchTechSteps`/`matchTechStepSpans` return * the whole *ordered sequence* they find, not a single winner, matching * `Step.techSteps` (schema.prisma's `StepTechStep`, an ordered join table). * * `normalizeText`/`matchTechStepSpans`/`matchTechSteps` are pure (no DB * access) so they can be unit-tested in isolation (see * `test/tech-step-matcher.test.ts`). `loadTechStepMappingRules` is the only * DB-touching piece, kept separate so callers (`recipe.service.ts`) fetch * the whole mapping list once per request and pass it to * `matchTechStepSpans` per step, rather than querying once per step. */ /** One `TechStepMapping` row, trimmed to what {@link matchTechSteps} needs. */ export interface TechStepMappingRule { techStepId: number; /** * Regex source, matched against the normalized description (see * {@link normalizeText}) — may itself contain accented characters, * normalized the same way before compiling. */ expression: string; weight: number; } /** * Lowercases and strips diacritics (NFD decomposition + removal of * combining marks, e.g. "Déglacer" -> "deglacer") — recipe step text and * mapping expressions are both run through this before matching, so * expressions can be authored with natural French accents in * `reference-seed-data.ts` while matching stays accent/case-insensitive. */ const COMBINING_DIACRITICS_PATTERN = /\p{Diacritic}/gu; export function normalizeText(text: string): string { return text.normalize("NFD").replace(COMBINING_DIACRITICS_PATTERN, "").toLowerCase(); } /** Where in the (normalized) description one mapping matched, alongside the rule that matched — the raw material {@link matchTechStepSpans} resolves into a final sequence. */ interface MatchCandidate extends TechStepMappingRule { start: number; end: number; } /** Whether two candidates' matched spans share any character position — the case where two *different* techniques' expressions matched the same words (e.g. generic `cook`'s "cuire" inside specific `bake`'s "cuire au four"), meaning only one of them should survive. */ function overlaps(a: MatchCandidate, b: MatchCandidate): boolean { return a.start < b.end && b.start < a.end; } /** * One technique {@link matchTechStepSpans} found, alongside exactly where in * `description` it matched — `[start, end)`, same convention as * `String.prototype.slice`. Persisted as `StepTechStep.start`/`end` * (`recipe.service.ts`) so the recipe detail view can highlight the exact * matched words, not just know a technique was mentioned somewhere. */ export interface TechStepMatch { techStepId: number; start: number; end: number; } /** * Detects every technique `description` mentions among `mappings`, as an * ordered sequence of matches (each carrying *where* it matched) — empty if * none match. The algorithm: * * 1. Test every mapping against the normalized description; each one that * matches becomes a candidate carrying *where* it matched (so * overlapping matches can be compared). * 2. Within a single technique, several of its own mappings might all * match (different phrasings for the same `techStepId`) — keep only * that technique's best candidate (highest weight, ties broken by * earliest match), the same tie-break this function always used for a * single winner. * 3. Across *different* techniques, two candidates can still overlap (a * generic pattern matching inside a more specific one's span, e.g. * `cook` vs `bake` both matching "cuire au four") — resolve greedily by * weight: take candidates highest-weight first, accept a candidate only * if it doesn't overlap one already accepted. This is what keeps * `bake` and drops the redundant `cook` for that phrase, while letting * two genuinely distinct, non-overlapping techniques (e.g. `preheat` * and `melt` in "Dans une poêle chaude, faire chauffer une noix de * beurre") both survive. * 4. Sort what's left by where it appears in the text — the sequence * reads in the same order as the instruction itself. * * The returned `start`/`end` are offsets into `normalizeText(description)`, * used as-is against the *original* `description` by callers that slice it * for display (`highlight-tech-steps.ts`, apps/web) — `normalizeText` only * strips diacritics/lowercases, which preserves character count for * realistic French text (canonical NFD decomposition never turns one * character into more than one base character), so this holds in practice. * A pathological input where it doesn't (e.g. a bare standalone `^`, which * `normalizeText` would strip as a diacritic) just produces a slightly * misplaced highlight — degrades silently, doesn't crash. * * Pure — takes `mappings` as a plain argument rather than querying Prisma * itself, so it's testable without a database (see * `loadTechStepMappingRules` for the DB-backed loader). `mappings` should * already be filtered to the locale the caller cares about — this function * has no notion of locale, it just tests the rules it's given. */ export function matchTechStepSpans( description: string, mappings: TechStepMappingRule[], ): TechStepMatch[] { const normalizedDescription = normalizeText(description); const candidates: MatchCandidate[] = []; for (const mapping of mappings) { const pattern = new RegExp(normalizeText(mapping.expression), "i"); const match = pattern.exec(normalizedDescription); if (match === null) continue; candidates.push({ ...mapping, start: match.index, end: match.index + match[0].length, }); } // Step 2: one best candidate per techStepId. const bestByTechStep = new Map(); for (const candidate of candidates) { const current = bestByTechStep.get(candidate.techStepId); if ( current === undefined || candidate.weight > current.weight || (candidate.weight === current.weight && candidate.start < current.start) ) { bestByTechStep.set(candidate.techStepId, candidate); } } // Step 3: resolve cross-technique overlaps, highest weight first. const byWeightDesc = [...bestByTechStep.values()].sort( (a, b) => b.weight - a.weight || a.techStepId - b.techStepId, ); const accepted: MatchCandidate[] = []; for (const candidate of byWeightDesc) { if (accepted.some((other) => overlaps(candidate, other))) continue; accepted.push(candidate); } // Step 4: reading order. accepted.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId); return accepted.map(({ techStepId, start, end }) => ({ techStepId, start, end, })); } /** * Convenience wrapper around {@link matchTechStepSpans} for callers that * only care about *which* techniques matched, not where — e.g. * `recipe-translation.ts`'s `translateRecipeSteps`, which declares a step's * technique sequence for an imported recipe that isn't saved (and so has no * `StepTechStep` row to persist a span into) yet. */ export function matchTechSteps(description: string, mappings: TechStepMappingRule[]): number[] { return matchTechStepSpans(description, mappings).map((match) => match.techStepId); } /** * Loads every `TechStepMapping` row for `locale` as * {@link TechStepMappingRule}s — meant to be fetched once per request by * `recipe.service.ts`'s `createRecipe`/`updateRecipe` and reused across * every step of the recipe being saved, not re-queried per step. * * No user-language preference exists anywhere in the app yet (a single * `"fr"` translation file, no locale field on `User`/`UserProfile`) — * callers pass a hardcoded locale for now; this parameter exists so that * plugging in a real user preference later doesn't require touching this * module. */ export async function loadTechStepMappingRules(locale: string): Promise { try { return await prisma.techStepMapping.findMany({ where: { locale }, select: { techStepId: true, expression: true, weight: true }, }); } catch (err) { // Rethrown as-is — the caller (`recipe.service.ts`/`sources.service.ts`) // already handles/logs failures centrally; this function just isn't // allowed a bare `async` body without a try/catch per the repo's // convention. throw err; } }