import { HttpError } from "@batch-cooking/error-tools"; import { ErrorCode, type PendingTechStepCorrectionView, type SubmitTrainingSuggestionsInput, type TechStepAuditClauseView, } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; import { CONFIDENCE_THRESHOLD, techStepClassifier, } from "../../lib/recipe-matching/tech-step-matcher.js"; /** * Read/write surface `services/tech-step-llm-worker` calls through * `/internal/tech-steps/*` (`tech-step-worker.routes.ts`, guarded by * `requireInternalWorker`) — the worker has no Prisma client or database * credentials of its own (see that service's own README), so every * corrections/audit-sample read and every suggestion write goes through * here rather than the worker touching this schema directly. Keeps * `apps/api` the single owner of the schema/migrations, and keeps the * worker a pure "read some text, run inference, post a suggestion" process * with nothing to keep in sync if the schema changes shape. */ /** * How many of the most recently created `Step`s {@link getAuditBatch} scans * per call before filtering down to low-confidence clauses — a fixed * recency-biased sample, not every `Step` in the database, to keep this * endpoint's cost bounded regardless of how large the recipe catalog gets. * Recently-added steps are also the steps most likely to still use * vocabulary the training corpus hasn't caught up with yet, which is * exactly what this audit is for. A smarter sampling strategy (e.g. * weighted by how often a recipe is actually viewed/planned) is future * work, not needed for this feature's first version. */ const AUDIT_SAMPLE_SIZE = 200; /** * Every low-confidence clause found across a recency-biased sample of * existing `Step`s (see {@link AUDIT_SAMPLE_SIZE}), for * `services/tech-step-llm-worker`'s `audit-low-confidence` job to get a * second opinion on. "Low-confidence" mirrors exactly what * `TechStepClassifierService._classifyClause` itself distrusts (a clause * with an NER anchor but a classifier score under * {@link CONFIDENCE_THRESHOLD}) — the same clauses that pipeline already * has to fall back to keyword-anchor guessing for, not an arbitrary * separate cutoff. */ export async function getAuditBatch( locale: string, limit: number, ): Promise { try { const steps = await prisma.step.findMany({ orderBy: { id: "desc" }, take: AUDIT_SAMPLE_SIZE, select: { id: true, recipeId: true, description: true }, }); const results: TechStepAuditClauseView[] = []; for (const step of steps) { if (results.length >= limit) break; const clauses = await techStepClassifier.classifyClauses(step.description, locale); for (const clause of clauses) { if (results.length >= limit) break; const isLowConfidence = clause.anchorUid !== null && clause.score < CONFIDENCE_THRESHOLD; if (!isLowConfidence) continue; results.push({ stepId: step.id, recipeId: step.recipeId, clauseText: clause.clauseText, anchorKey: clause.anchorUid, intentKey: clause.intentUid, score: clause.score, locale, }); } } return results; } catch (err) { throw err; // see recipe.service.ts's equivalent catch comment } } /** * Every `StepTechStepCorrection` not yet turned into a * `TechStepTrainingSuggestion` (`consumedAt IS NULL`), oldest first — a * FIFO queue the worker's `transform-corrections` job drains, `limit` at a * time. * * `correctedTechStepId IS NOT NULL` on top of `consumedAt IS NULL`: a * correction that *removes* a match ("no technique belongs here", * `correctedTechStepId: null` — see `StepTechStepCorrection`'s schema doc * comment) has no technique to propose new positive training data *for*. * Surfacing it here would leave it permanently unconsumable (the worker * has nothing to submit a suggestion for, so it would never stamp * `consumedAt`, and it would keep re-appearing in every future batch * forever) — excluded at the source instead, not filtered/skipped * downstream by the worker. */ export async function getPendingCorrections( limit: number, ): Promise { try { const corrections = await prisma.stepTechStepCorrection.findMany({ where: { consumedAt: null, correctedTechStepId: { not: null } }, orderBy: { createdAt: "asc" }, take: limit, include: { step: { select: { id: true, recipeId: true, description: true } }, previousTechStep: { select: { key: true } }, correctedTechStep: { select: { key: true } }, }, }); return corrections.map((correction) => ({ id: correction.id, stepId: correction.step.id, recipeId: correction.step.recipeId, clauseText: correction.step.description.slice(correction.start, correction.end), start: correction.start, end: correction.end, previousTechStepKey: correction.previousTechStep?.key ?? null, correctedTechStepKey: correction.correctedTechStep?.key ?? null, })); } catch (err) { throw err; // see recipe.service.ts's equivalent catch comment } } /** * Persists a batch of `TechStepTrainingSuggestion`s and, for every * suggestion sourced from a correction, stamps that correction's * `consumedAt` in the same transaction — so a worker run that crashes * partway through never leaves a correction consumed with no matching * suggestion, or a suggestion created against a correction still (wrongly) * eligible to be picked up again by the next run. * * @throws {HttpError} `404 TECH_STEP_NOT_FOUND` if any `techStepKey` in the * batch doesn't match a reference `TechStep` — rejects the *whole* batch * rather than skipping the bad entries, on the theory that a worker * sending an unknown key is more likely a version-skew bug (its own * taxonomy copy, `services/tech-step-llm-worker/src/tech-step-taxonomy.ts`, * drifting from this API's `TechStep` catalog) than a one-off it should * silently tolerate. */ export async function submitTrainingSuggestions( input: SubmitTrainingSuggestionsInput, ): Promise<{ created: number }> { try { const techStepKeys = [ ...new Set(input.suggestions.map((suggestion) => suggestion.techStepKey)), ]; const techSteps = await prisma.techStep.findMany({ where: { key: { in: techStepKeys } }, select: { id: true, key: true }, }); const techStepIdByKey = new Map(techSteps.map((techStep) => [techStep.key, techStep.id])); const missingKeys = techStepKeys.filter((key) => !techStepIdByKey.has(key)); if (missingKeys.length > 0) { throw new HttpError( 404, ErrorCode.TECH_STEP_NOT_FOUND, `Unknown techStepKey(s): ${missingKeys.join(", ")}`, ); } await prisma.$transaction(async (tx) => { for (const suggestion of input.suggestions) { // Non-null by construction — every key in `input.suggestions` was // just confirmed present in `techStepIdByKey` above (the `missingKeys` // check would have thrown otherwise). const techStepId = techStepIdByKey.get(suggestion.techStepKey); if (techStepId === undefined) continue; await tx.techStepTrainingSuggestion.create({ data: { techStepId, locale: suggestion.locale, suggestedSynonyms: suggestion.suggestedSynonyms, suggestedUtterances: suggestion.suggestedUtterances, sourceType: suggestion.sourceType, sourceCorrectionId: suggestion.sourceCorrectionId ?? null, }, }); if (suggestion.sourceCorrectionId !== null && suggestion.sourceCorrectionId !== undefined) { await tx.stepTechStepCorrection.update({ where: { id: suggestion.sourceCorrectionId }, data: { consumedAt: new Date() }, }); } } }); return { created: input.suggestions.length }; } catch (err) { throw err; // see recipe.service.ts's equivalent catch comment } }