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 | undefined; private _techStepIdByUid: Map | 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 | undefined; private _utensilIdByUid: Map | 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { if (this._utensilIdsLoaded === undefined) { this._utensilIdsLoaded = this._loadUtensilIds(); } try { await this._utensilIdsLoaded; } catch (err) { this._utensilIdsLoaded = undefined; throw err; } } private async _loadUtensilIds(): Promise { 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();