import path from "node:path"; import { fileURLToPath } from "node:url"; import { getLlama, type Llama, LlamaChatSession, type LlamaContext, type LlamaJsonSchemaGrammar, type LlamaModel, resolveModelFile, } from "node-llama-cpp"; import { env } from "./config.js"; /** * Local LLM inference for this worker's two jobs — model loading/grammar * compilation ported from `experiments/llm-tech-step-poc/src/llm-tech-step-poc.ts`'s * `LocalLlmStepAnalyzer` (same `node-llama-cpp` API: `getLlama()` -> * `loadModel()` -> `createContext()` -> `createGrammarForJsonSchema()`, a * fresh `LlamaContextSequence` allocated and disposed per call rather than * a shared `LlamaChatSession` growing its own history across calls), *not* * copied wholesale — this worker judges against the real ~26-key `TechStep` * taxonomy (fetched at runtime, see `tech-step-taxonomy.ts`), not that * PoC's own fixed 7-category `KitchenActionType`, and needs two distinct * tasks (clause verdict, training-data suggestion) rather than that PoC's * one full-step structuring task — so the schemas/prompts here are new, * only the surrounding model-lifecycle mechanics are reused. */ /** Directory GGUF weights are downloaded/cached in — gitignored, same convention as the PoC's own `models/` directory next to it. */ const MODELS_DIRECTORY = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "models"); async function resolveModelPath(): Promise { if (env.TECH_STEP_LLM_MODEL_PATH !== undefined && env.TECH_STEP_LLM_MODEL_PATH.length > 0) { return env.TECH_STEP_LLM_MODEL_PATH; } return await resolveModelFile(env.TECH_STEP_LLM_MODEL_URI, MODELS_DIRECTORY); } /** JSON shape `judgeClause` asks the model for — `techStepKey` constrained (via {@link buildClauseVerdictSchema}) to exactly the taxonomy's own keys, plus `null` for "none of them". */ interface ClauseVerdictResult { techStepKey: string | null; } /** Builds the JSON schema constraining `judgeClause`'s output to one of `techStepKeys`, or `null` — compiled fresh per {@link TechStepLlmService.initialize} call since the taxonomy (and so the valid `enum` values) is only known once fetched from the API, not at module-load time. */ function buildClauseVerdictSchema(techStepKeys: string[]) { return { type: "object", properties: { techStepKey: { oneOf: [{ type: "null" }, { enum: techStepKeys }] }, }, required: ["techStepKey"], } as const; } /** JSON shape `suggestTrainingData` asks the model for. Array sizes are steered by the prompt (`buildSuggestionSystemPrompt`'s "at most 3"/"at most 2"), not the grammar itself — `experiments/llm-tech-step-poc`'s own schemas never constrained array length either, and adding an unfamiliar JSON-schema keyword here risked breaking grammar compilation for no proven benefit. */ const TRAINING_SUGGESTION_JSON_SCHEMA = { type: "object", properties: { suggestedSynonyms: { type: "array", items: { type: "string" } }, suggestedUtterances: { type: "array", items: { type: "string" } }, }, required: ["suggestedSynonyms", "suggestedUtterances"], } as const; /** Result shape for {@link TechStepLlmService.suggestTrainingData}. */ export interface TrainingSuggestionResult { suggestedSynonyms: string[]; suggestedUtterances: string[]; } function buildClauseVerdictSystemPrompt(techStepKeys: string[]): string { return `You are a culinary technique classifier. You receive one short clause from a recipe step, written in French or English, and a fixed list of known technique keys. Decide which single technique from the list the clause most likely describes — including when it describes the technique without ever naming it (e.g. "until the butter has disappeared into the pan" means "melt"). If the clause doesn't clearly describe any technique in the list, answer null. Respond with ONLY the JSON object required by the schema — no prose, no markdown. Known technique keys: ${techStepKeys.join(", ")}`; } /** French clitic-pronoun lesson from `experiments/llm-tech-step-poc/src/nlp-tech-step-poc.ts` (e.g. "faites-les revenir" breaking a "faire revenir" match) encoded directly into the prompt — a suggestion generated without this steer would reproduce the exact multi-word-synonym trap that PoC found and this feature's plan calls out. */ function buildSuggestionSystemPrompt(techStepKey: string, locale: string): string { return `You are helping expand a training corpus (locale "${locale}") for a cooking-technique detector. You receive a real recipe clause a human has confirmed means the technique "${techStepKey}". Suggest at most 3 short new synonym words/phrases for this technique, and at most 2 example training sentences that use it in context (paraphrases are welcome, not just the literal clause). Prefer single-word verb forms over multi-word phrases when both would work — a multi-word phrase like "faire revenir" can silently fail to match a real sentence like "faites-les revenir" (French object pronoun inserted between the two words), while the bare verb "revenir" still would. Respond with ONLY the JSON object required by the schema — no prose, no markdown.`; } /** * Owns the loaded model/context/compiled grammars for this worker's whole * run — a real class (not a plain object), same "holds real, expensive-to- * rebuild state" reasoning as `TechStepClassifierService` * (`apps/api/src/lib/recipe-matching/tech-step-matcher.ts`) and the PoC's * own `LocalLlmStepAnalyzer`. */ export class TechStepLlmService { private _llama: Llama | undefined; private _model: LlamaModel | undefined; private _context: LlamaContext | undefined; private _verdictGrammar: | LlamaJsonSchemaGrammar> | undefined; private _suggestionGrammar: | LlamaJsonSchemaGrammar | undefined; private _techStepKeys: string[] = []; /** * Loads the model, creates its inference context, and compiles both * grammars — `techStepKeys` (from `loadTechStepTaxonomy`) is what the * verdict grammar's `enum` is built from, so it must be known before this * can complete (see {@link buildClauseVerdictSchema}). */ public async initialize(techStepKeys: string[]): Promise { this._techStepKeys = techStepKeys; const modelPath = await resolveModelPath(); this._llama = await getLlama(); this._model = await this._llama.loadModel({ modelPath }); this._context = await this._model.createContext({ contextSize: 4096 }); this._verdictGrammar = await this._llama.createGrammarForJsonSchema( buildClauseVerdictSchema(techStepKeys), ); this._suggestionGrammar = await this._llama.createGrammarForJsonSchema( TRAINING_SUGGESTION_JSON_SCHEMA, ); } /** * Judges one clause against the taxonomy `initialize` was given, and * returns its verdict's technique `key`, or `null` for "none of them * clearly". A grammar-invalid or empty response degrades to `null` * (treated as "no opinion") rather than throwing — one bad generation * shouldn't abort the whole scheduled run over a single clause. */ public async judgeClause(clauseText: string): Promise { if (this._context === undefined || this._verdictGrammar === undefined) { throw new Error("TechStepLlmService.initialize() must be awaited before judgeClause()."); } const context = this._context; const grammar = this._verdictGrammar; const sequence = context.getSequence(); try { const session = new LlamaChatSession({ contextSequence: sequence, systemPrompt: buildClauseVerdictSystemPrompt(this._techStepKeys), }); const response = await session.prompt(clauseText, { grammar }); const parsed = grammar.parse(response) as ClauseVerdictResult; return parsed.techStepKey; } catch { return null; } finally { await sequence.dispose(); } } /** * Proposes candidate synonyms/utterances for `techStepKey` from one * confirmed clause. An invalid/empty response degrades to an empty * suggestion (`{ suggestedSynonyms: [], suggestedUtterances: [] }`) — * `transform-corrections` skips posting a suggestion that came back * empty on both arrays, rather than treating a bad generation as a * job-ending failure. */ public async suggestTrainingData( clauseText: string, techStepKey: string, locale: string, ): Promise { if (this._context === undefined || this._suggestionGrammar === undefined) { throw new Error( "TechStepLlmService.initialize() must be awaited before suggestTrainingData().", ); } const context = this._context; const grammar = this._suggestionGrammar; const sequence = context.getSequence(); try { const session = new LlamaChatSession({ contextSequence: sequence, systemPrompt: buildSuggestionSystemPrompt(techStepKey, locale), }); const response = await session.prompt(clauseText, { grammar }); return grammar.parse(response) as TrainingSuggestionResult; } catch { return { suggestedSynonyms: [], suggestedUtterances: [] }; } finally { await sequence.dispose(); } } /** Releases the model/context — native memory, not managed by V8's GC. Called once per scheduled run (see `scheduler.ts`) rather than kept loaded between runs, so the process's RAM footprint returns to idle between them. */ public async dispose(): Promise { await this._context?.dispose(); await this._model?.dispose(); } }