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>
618 lines
29 KiB
TypeScript
618 lines
29 KiB
TypeScript
import { prisma } from "../../db/prisma.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
|
|
* step description corresponds to — 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`).
|
|
*
|
|
* Regex-only matching used to live here (matching literal verb-form
|
|
* patterns from a DB-backed `TechStepMapping` table) but couldn't
|
|
* 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
|
|
* regex could anchor on, yet unmistakably *means* `melt`. Replaced with a
|
|
* 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** (the intent service's `PhraseMatcher`, built from its own
|
|
* `training_data.py`'s `synonyms`) finds every *candidate* technique
|
|
* mention in the whole description, each with its exact character span —
|
|
* mechanically the same job the old regexes did, just as flat synonym
|
|
* lists instead of hand-written patterns. This step alone is *not* the
|
|
* final answer — see step 3.
|
|
* 2. The description is cut into clauses around those candidate spans
|
|
* ({@link splitIntoClauses}) — a step naming two techniques ("Dans une
|
|
* poêle chaude, faire chauffer une noix de beurre" is both `preheat`
|
|
* and `melt`) needs each judged on its own surrounding context, not the
|
|
* whole step lumped into one classification.
|
|
* 3. **NLP intent classification** (the intent service's `textcat`, trained
|
|
* on its own `training_data.py`'s `utterances`) then classifies each
|
|
* clause on its own — this is what actually delivers "meaning, not
|
|
* keywords": the classifier was deliberately trained on paraphrases that
|
|
* 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
|
|
* was trained to recognize as *meaning* a technique, not by which
|
|
* literal word the NER step happened to anchor on. The NER-implied
|
|
* technique is kept only as a fallback for a clause the classifier
|
|
* isn't confident about (see `CONFIDENCE_THRESHOLD`) — a clearly
|
|
* keyword-anchored clause a small model merely isn't sure how to
|
|
* classify shouldn't be dropped outright.
|
|
*
|
|
* A single instruction can genuinely involve more than one technique — see
|
|
* point 2 above — so `matchTechSteps`/`matchTechStepSpans` both return the
|
|
* whole *ordered sequence* they find, not a single winner, matching
|
|
* `Step.techSteps` (schema.prisma's `StepTechStep`, an ordered join table).
|
|
*
|
|
* `normalizeText` and {@link splitIntoClauses} are pure (no DB/model
|
|
* access) so they stay unit-testable in isolation (see
|
|
* `test/tech-step-matcher.test.ts`); this class only ever needs a
|
|
* `TechStep.key -> id` lookup from the DB, memoized on the shared
|
|
* {@link techStepClassifier} singleton rather than repeated per call — the
|
|
* NLP model itself trains once, inside `services/tech-step-intent-service`'s
|
|
* own startup, entirely independently of this class (see that service's
|
|
* README — this repo no longer pushes any corpus to it over HTTP).
|
|
*/
|
|
|
|
/**
|
|
* Lowercases and strips diacritics (NFD decomposition + removal of
|
|
* combining marks, e.g. "Déglacer" -> "deglacer"). Still used by
|
|
* `ingredient-matcher.ts` for its own, unrelated free-text matching — kept
|
|
* here and exported rather than duplicated, this module owned it first.
|
|
*/
|
|
const COMBINING_DIACRITICS_PATTERN = /\p{Diacritic}/gu;
|
|
|
|
export function normalizeText(text: string): string {
|
|
return text.normalize("NFD").replace(COMBINING_DIACRITICS_PATTERN, "").toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* One technique {@link matchTechStepSpans} found, alongside exactly where in
|
|
* `description` it matched — two nested spans, both `[start, end)` (same
|
|
* convention as `String.prototype.slice`):
|
|
*
|
|
* - `start`/`end` — the tight *keyword* span (e.g. "préchauffer") that
|
|
* directly triggered the match, or (when no NER anchor exists at all —
|
|
* see {@link splitIntoClauses}'s zero-candidate case) the whole clause,
|
|
* same as `contextStart`/`contextEnd` below.
|
|
* - `contextStart`/`contextEnd` — the wider *clause* the keyword was found
|
|
* in (e.g. "Dans une poêle chaude" for a `preheat` keyword of "poêle
|
|
* chaude") — what actually got fed to the classifier (see this file's
|
|
* doc comment, point 3), kept alongside the tight span so a caller can
|
|
* show *both*: the exact trigger word(s), and how much of the sentence
|
|
* is understood to be about that technique. Always contains `start`/`end`
|
|
* (`contextStart <= start`, `end <= contextEnd`).
|
|
*
|
|
* Persisted as `StepTechStep.start`/`end`/`contextStart`/`contextEnd`
|
|
* (`recipe.service.ts`) so the recipe detail view can highlight both spans,
|
|
* 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 {
|
|
techStepId: number;
|
|
start: number;
|
|
end: number;
|
|
contextStart: 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. */
|
|
export interface TechniqueCandidate {
|
|
/** Training-data `uid` this candidate's synonym belongs to (e.g. `"melt"`) — not yet resolved to a DB id at this stage. */
|
|
uid: string;
|
|
start: number;
|
|
end: number;
|
|
}
|
|
|
|
/** One clause {@link splitIntoClauses} produced — `anchor` is `null` only for the single "whole description, no candidate found at all" fallback clause (see that function's doc comment). */
|
|
export interface TechStepClause {
|
|
/** `[start, end)` into the original description — the text handed to the classifier for this clause, and (see {@link TechStepMatch}) what ends up as a match's `contextStart`/`contextEnd`. */
|
|
start: number;
|
|
end: number;
|
|
/** The candidate this clause was cut around, if any — its own (tighter) span is what gets persisted as a match's `start`/`end` for the keyword highlight, the wider clause span is always its `contextStart`/`contextEnd`. */
|
|
anchor: TechniqueCandidate | null;
|
|
}
|
|
|
|
/** Matches a sentence-ending punctuation mark, for {@link findGapSplitPoint}'s preferred split points. */
|
|
const SENTENCE_END_PATTERN = /[.!?]/;
|
|
|
|
/**
|
|
* Picks where to cut the gap `[gapStart, gapEnd)` between two consecutive
|
|
* candidates — preferring a *sentence* boundary (right after `.`/`!`/`?`)
|
|
* nearest the gap's midpoint when one exists in the gap, otherwise any
|
|
* whitespace nearest the midpoint, so a clause boundary (surfaced to users
|
|
* as `contextStart`/`contextEnd`, unlike a keyword's own `[start, end)`
|
|
* which always lands on a real word by construction) never slices through
|
|
* the middle of a word — found while testing a context span that cut
|
|
* "poêle" into "poêl"/"e" across two clauses.
|
|
*
|
|
* The sentence-boundary preference matters beyond cosmetics: a description
|
|
* with two techniques in two different sentences ("Préchauffer le four à
|
|
* 180°C. Dans un saladier, mettre le beurre... et mélanger.") used to only
|
|
* get a plain nearest-midpoint whitespace split, which for a long first
|
|
* sentence lands *inside* the second one — handing the classifier a clause
|
|
* like "...(thermostat 6). Dans un saladier, mettre" that trails off
|
|
* mid-instruction with no object. That garbled, incomplete text is nothing
|
|
* like the short, complete training utterances, and was found to
|
|
* misclassify real recipe steps with high (>0.65) confidence in both
|
|
* halves — "Préchauffer..." scored as `mix`, its actual "mélanger" clause
|
|
* as `melt`. Splitting at the real sentence boundary instead hands the
|
|
* classifier two complete, grammatical clauses, each far closer to what it
|
|
* was trained on.
|
|
*
|
|
* Falls back to the raw midpoint when the gap has no whitespace at all
|
|
* (adjacent candidates, or a gap that's pure punctuation with no space) —
|
|
* same "some split point, however imperfect" fallback a plain midpoint
|
|
* always was.
|
|
*/
|
|
function findGapSplitPoint(description: string, gapStart: number, gapEnd: number): number {
|
|
if (gapStart >= gapEnd) return gapStart;
|
|
const midpoint = Math.floor((gapStart + gapEnd) / 2);
|
|
|
|
let bestSentenceEnd: number | null = null;
|
|
let bestSentenceEndDistance = Number.POSITIVE_INFINITY;
|
|
let bestWhitespace: number | null = null;
|
|
let bestWhitespaceDistance = Number.POSITIVE_INFINITY;
|
|
for (let i = gapStart; i < gapEnd; i++) {
|
|
if (!/\s/.test(description[i] ?? "")) continue;
|
|
const distance = Math.abs(i - midpoint);
|
|
if (distance < bestWhitespaceDistance) {
|
|
bestWhitespace = i;
|
|
bestWhitespaceDistance = distance;
|
|
}
|
|
if (
|
|
i > gapStart &&
|
|
SENTENCE_END_PATTERN.test(description[i - 1] ?? "") &&
|
|
distance < bestSentenceEndDistance
|
|
) {
|
|
bestSentenceEnd = i;
|
|
bestSentenceEndDistance = distance;
|
|
}
|
|
}
|
|
return bestSentenceEnd ?? bestWhitespace ?? midpoint;
|
|
}
|
|
|
|
/**
|
|
* Cuts `description` into clauses around `candidates` (NER's found
|
|
* technique mentions, already sorted or not — sorted internally), one
|
|
* clause per candidate, so each can be judged by the classifier on its own
|
|
* surrounding context rather than the whole (possibly multi-technique)
|
|
* description at once.
|
|
*
|
|
* - **Zero candidates**: the whole description is one clause with no
|
|
* anchor — still worth classifying (a description mentioning no literal
|
|
* keyword at all can still *mean* a technique, the entire point of the
|
|
* classification step), just with no tight span to highlight, so callers
|
|
* fall back to highlighting the whole thing.
|
|
* - **One candidate**: the whole description is one clause too (nothing to
|
|
* cut around a single mention), but *with* that candidate as its anchor
|
|
* — callers get its tight span for highlighting.
|
|
* - **Two or more**: split points fall at the whitespace nearest the
|
|
* midpoint of each consecutive pair's `[end, nextStart]` gap (see
|
|
* {@link findGapSplitPoint} — never mid-word), producing that many
|
|
* contiguous, non-overlapping clauses covering the whole description —
|
|
* clause *i* is anchored on candidate *i*.
|
|
*
|
|
* Pure and DB/model-free — unit-tested directly (see
|
|
* `test/tech-step-matcher.test.ts`) without needing a trained classifier.
|
|
*/
|
|
export function splitIntoClauses(
|
|
description: string,
|
|
candidates: TechniqueCandidate[],
|
|
): TechStepClause[] {
|
|
if (candidates.length === 0) {
|
|
return [{ start: 0, end: description.length, anchor: null }];
|
|
}
|
|
|
|
const sorted = [...candidates].sort((a, b) => a.start - b.start);
|
|
const [first, ...rest] = sorted;
|
|
if (first === undefined) {
|
|
// Unreachable — `candidates.length === 0` already returned above, so
|
|
// `sorted` (same length) always has a first element here. Satisfies
|
|
// `noUncheckedIndexedAccess`, which can't see that from the length
|
|
// check alone.
|
|
return [{ start: 0, end: description.length, anchor: null }];
|
|
}
|
|
|
|
// Single pass, pairing each candidate with the next one as it goes —
|
|
// avoids re-indexing a separately-built `splitPoints` array afterward
|
|
// (also awkward under `noUncheckedIndexedAccess` for no real benefit,
|
|
// since every split point is only ever read once, right after it's
|
|
// computed).
|
|
const clauses: TechStepClause[] = [];
|
|
let clauseStart = 0;
|
|
let anchor = first;
|
|
for (const next of rest) {
|
|
const splitPoint = findGapSplitPoint(description, anchor.end, next.start);
|
|
clauses.push({ start: clauseStart, end: splitPoint, anchor });
|
|
clauseStart = splitPoint;
|
|
anchor = next;
|
|
}
|
|
clauses.push({ start: clauseStart, end: description.length, anchor });
|
|
return clauses;
|
|
}
|
|
|
|
/**
|
|
* Below this confidence, a clause's classifier verdict isn't trusted on its
|
|
* own — falls back to its NER anchor's own technique instead (see this
|
|
* file's doc comment, point 3). Tuned empirically against
|
|
* `TECH_STEP_TRAINING_DATA` — see `test/tech-step-matcher.test.ts` for the
|
|
* cases this threshold was picked to pass.
|
|
*
|
|
* Recalibrated for the migration off `node-nlp` to
|
|
* `services/tech-step-intent-service` (spaCy `textcat`, exclusive classes)
|
|
* — its score distribution is meaningfully different from node-nlp's own
|
|
* classifier, and shifts again every time the corpus' technique count
|
|
* changes (more exclusive classes generally means a *lower* natural
|
|
* confidence ceiling, softmax mass spread thinner).
|
|
*
|
|
* Currently `0.25`, set against the corpus as expanded to ~74 techniques
|
|
* (`services/tech-step-intent-service/intent_service/training_data.py`,
|
|
* `_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.
|
|
*/
|
|
export const CONFIDENCE_THRESHOLD = 0.25;
|
|
|
|
/**
|
|
* One clause's full classification detail — the finer-grained sibling of
|
|
* {@link TechStepMatch}, exposing the raw intent/score
|
|
* `TechStepClassifierService`'s private `_classifyClause` normally
|
|
* collapses into a single accepted-or-fallback verdict. Nothing on the
|
|
* interactive save/read path needs this (that's exactly what
|
|
* `_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 interface TechStepClauseClassification {
|
|
/** The clause's own text (`description.slice(start, end)`, trimmed). */
|
|
clauseText: string;
|
|
start: number;
|
|
end: number;
|
|
/** The clause's NER anchor's own implied technique `uid`, if it had one — same as `TechStepClause.anchor.uid`. */
|
|
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. */
|
|
intentUid: string | null;
|
|
/** The intent classifier's own confidence for `intentUid` — `0` when `intentUid` is `null` (nothing to have a score about). */
|
|
score: number;
|
|
}
|
|
|
|
/**
|
|
* Owns the `TechStep.key -> id` lookup behind {@link matchTechStepSpans} —
|
|
* a real class (not a plain object of functions) per this repo's
|
|
* service-style-logic convention, even though it's only ever used as the
|
|
* one shared {@link techStepClassifier} singleton below: it holds real
|
|
* state (the memoized lookup promise), not just grouped stateless helpers.
|
|
* The actual NER/intent-classification model lives entirely in
|
|
* `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> {
|
|
try {
|
|
await this.matchTechStepSpans("faire cuire à feu doux", "fr");
|
|
} catch (err) {
|
|
throw err; // see matchTechStepSpans()'s catch comment above
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detects every technique `description` means, as an ordered sequence of
|
|
* matches (each carrying *where* it matched) — empty if none apply. See
|
|
* this file's doc comment for the full NER -> split -> classify
|
|
* pipeline.
|
|
*
|
|
* @param locale Which of `TECH_STEP_TRAINING_DATA`'s locales to match
|
|
* against — same "caller already knows/validated this" contract the
|
|
* old `matchTechStepSpans(description, mappings)` had via its
|
|
* pre-filtered `mappings` argument, just as an explicit parameter now
|
|
* that the training data isn't pre-filtered by the caller anymore.
|
|
*/
|
|
public async matchTechStepSpans(description: string, locale: string): Promise<TechStepMatch[]> {
|
|
try {
|
|
await Promise.all([this._ensureTechStepIdsLoaded(), this._ensureUtensilIdsLoaded()]);
|
|
if (description.trim().length === 0) return [];
|
|
|
|
// 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
|
|
.filter((entity) => entity.kind === "technique")
|
|
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
|
|
const utensilEntities = nerResult.entities.filter((entity) => entity.kind === "utensil");
|
|
|
|
const clauses = splitIntoClauses(description, candidates);
|
|
const matches: TechStepMatch[] = [];
|
|
for (const clause of clauses) {
|
|
const uid = await this._classifyClause(description, clause, locale);
|
|
if (uid === null) continue;
|
|
const techStepId = this._techStepIdByUid?.get(uid);
|
|
// A `uid` the classifier/NER was trained on but that no longer has
|
|
// a matching `TechStep` row (e.g. training data and
|
|
// `reference-seed-data.ts` drifted apart) — skip rather than
|
|
// persist a dangling id.
|
|
if (techStepId === undefined) continue;
|
|
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({
|
|
techStepId,
|
|
start: span.start,
|
|
end: span.end,
|
|
contextStart: clause.start,
|
|
contextEnd: clause.end,
|
|
ingredients,
|
|
utensils,
|
|
});
|
|
}
|
|
|
|
matches.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId);
|
|
return matches;
|
|
} 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` without a
|
|
// try/catch per the repo's convention.
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* 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.
|
|
*/
|
|
public async matchTechSteps(description: string, locale: string): Promise<number[]> {
|
|
try {
|
|
return (await this.matchTechStepSpans(description, locale)).map((match) => match.techStepId);
|
|
} catch (err) {
|
|
throw err; // see matchTechStepSpans()'s catch comment above
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Classifies one clause, returning the technique `uid` it means (or
|
|
* `null` if none applies) — the classifier's own verdict when it's
|
|
* confident enough ({@link CONFIDENCE_THRESHOLD}), otherwise the
|
|
* clause's NER anchor (if it has one) as a floor: a clearly
|
|
* keyword-anchored clause a small model merely isn't sure how to
|
|
* classify shouldn't be dropped outright, only a genuinely
|
|
* anchor-less/low-confidence one should.
|
|
*/
|
|
private async _classifyClause(
|
|
description: string,
|
|
clause: TechStepClause,
|
|
locale: string,
|
|
): Promise<string | null> {
|
|
try {
|
|
const clauseText = description.slice(clause.start, clause.end).trim();
|
|
if (clauseText.length === 0) return clause.anchor?.uid ?? null;
|
|
|
|
const result = await intentServiceClient.process(locale, clauseText);
|
|
if (result.intent !== null && result.score >= CONFIDENCE_THRESHOLD) {
|
|
return result.intent;
|
|
}
|
|
return clause.anchor?.uid ?? null;
|
|
} catch (err) {
|
|
throw err; // see matchTechStepSpans()'s catch comment above
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolves the `uid -> TechStep.id` lookup exactly once — memoized on
|
|
* `_techStepIdsLoaded` so a burst of concurrent calls (several steps of
|
|
* the same recipe save, awaited via the same event loop tick) all await
|
|
* the one in-flight DB query rather than each firing their own.
|
|
*/
|
|
private async _ensureTechStepIdsLoaded(): Promise<void> {
|
|
if (this._techStepIdsLoaded === undefined) {
|
|
this._techStepIdsLoaded = this._loadTechStepIds();
|
|
}
|
|
try {
|
|
await this._techStepIdsLoaded;
|
|
} catch (err) {
|
|
// A failed load must be retried by the *next* call, not leave every
|
|
// future call permanently rejecting against a stale failed promise.
|
|
this._techStepIdsLoaded = undefined;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
private async _loadTechStepIds(): Promise<void> {
|
|
try {
|
|
const techSteps = await prisma.techStep.findMany({ select: { id: true, key: true } });
|
|
this._techStepIdByUid = new Map(techSteps.map((techStep) => [techStep.key, techStep.id]));
|
|
} catch (err) {
|
|
throw err; // see matchTechStepSpans()'s catch comment above
|
|
}
|
|
}
|
|
|
|
/** `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) {
|
|
throw err; // see matchTechStepSpans()'s catch comment above
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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();
|