import { HttpError } from "@batch-cooking/error-tools"; import { type CorrectionAdminView, ErrorCode, type ListCorrectionsQuery, type ListSuggestionsQuery, type RetrainRequestInput, type RetrainResultView, type TrainingDataSnippetQuery, type TrainingDataSnippetView, type TrainingSuggestionAdminView, type TrainingSuggestionGroupView, type UpdateTrainingSuggestionInput, } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; import { MIN_OVERALL_F1, runTechStepEvalSuite, } from "../../lib/recipe-matching/tech-step-eval-runner.js"; import { backfillTechSteps } from "../../scripts/backfill-tech-steps.js"; /** How many raw corrections `listCorrections` returns per call — the browser is a triage view, not an export. */ const CORRECTIONS_PAGE_SIZE = 200; const suggestionInclude = { techStep: { select: { key: true } }, sourceCorrection: { include: { step: { select: { id: true, recipeId: true, description: true } }, previousTechStep: { select: { key: true } }, correctedTechStep: { select: { key: true } }, }, }, } as const; type SuggestionRow = Awaited< ReturnType< typeof prisma.techStepTrainingSuggestion.findFirstOrThrow<{ include: typeof suggestionInclude }> > >; /** Shapes one Prisma suggestion row (with {@link suggestionInclude}) into its admin view. */ function toSuggestionView(row: SuggestionRow): TrainingSuggestionAdminView { const correction = row.sourceCorrection; return { id: row.id, techStepKey: row.techStep.key, locale: row.locale, suggestedSynonyms: row.suggestedSynonyms, suggestedUtterances: row.suggestedUtterances, sourceType: row.sourceType, status: row.status, createdAt: row.createdAt.toISOString(), sourceCorrection: correction ? { id: correction.id, recipeId: correction.step.recipeId, stepId: correction.step.id, clauseText: correction.step.description.slice(correction.start, correction.end), previousTechStepKey: correction.previousTechStep?.key ?? null, correctedTechStepKey: correction.correctedTechStep?.key ?? null, } : null, }; } /** * Every `TechStepTrainingSuggestion` matching the (all-optional) filters, * grouped by technique key — same "one block per technique" organisation * as `list-pending-training-suggestions.ts`'s CLI report, which this UI * replaces. */ export async function listSuggestions( query: ListSuggestionsQuery, ): Promise { try { const rows = await prisma.techStepTrainingSuggestion.findMany({ where: { ...(query.status ? { status: query.status } : {}), ...(query.sourceType ? { sourceType: query.sourceType } : {}), ...(query.locale ? { locale: query.locale } : {}), ...(query.techStepKey ? { techStep: { key: query.techStepKey } } : {}), }, orderBy: [{ techStepId: "asc" }, { createdAt: "asc" }], include: suggestionInclude, }); const byKey = new Map(); for (const row of rows) { const view = toSuggestionView(row); const group = byKey.get(view.techStepKey); if (group) group.push(view); else byKey.set(view.techStepKey, [view]); } return [...byKey.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([techStepKey, suggestions]) => ({ techStepKey, suggestions })); } catch (err) { throw err; // see recipe.service.ts's equivalent catch comment } } /** * Raw `StepTechStepCorrection`s for the admin browser — newest first, * capped at {@link CORRECTIONS_PAGE_SIZE}. Unlike the worker's own * `getPendingCorrections`, this **includes** the `correctedTechStepId IS NULL` * removals ("no technique here") that never become suggestions and are * otherwise invisible. */ export async function listCorrections(query: ListCorrectionsQuery): Promise { try { const consumedFilter = query.consumed === "true" ? { consumedAt: { not: null } } : query.consumed === "false" ? { consumedAt: null } : {}; const correctedFilter = query.hasCorrectedTechStep === "true" ? { correctedTechStepId: { not: null } } : query.hasCorrectedTechStep === "false" ? { correctedTechStepId: null } : {}; const rows = await prisma.stepTechStepCorrection.findMany({ where: { ...consumedFilter, ...correctedFilter }, orderBy: { createdAt: "desc" }, take: CORRECTIONS_PAGE_SIZE, include: { step: { select: { id: true, recipeId: true, description: true } }, previousTechStep: { select: { key: true } }, correctedTechStep: { select: { key: true } }, }, }); return rows.map((row) => ({ id: row.id, recipeId: row.step.recipeId, stepId: row.step.id, stepDescription: row.step.description, clauseText: row.step.description.slice(row.start, row.end), start: row.start, end: row.end, previousTechStepKey: row.previousTechStep?.key ?? null, correctedTechStepKey: row.correctedTechStep?.key ?? null, createdAt: row.createdAt.toISOString(), consumedAt: row.consumedAt?.toISOString() ?? null, })); } catch (err) { throw err; } } /** * Curates one suggestion — edit its proposed synonyms/utterances and/or * flip its `status`. At least one field must be present. * * @throws {HttpError} `400 VALIDATION_ERROR` if the body is empty. * @throws {HttpError} `404 NOT_FOUND` if `id` matches no suggestion. */ export async function updateSuggestion( id: number, input: UpdateTrainingSuggestionInput, ): Promise { try { if ( input.status === undefined && input.suggestedSynonyms === undefined && input.suggestedUtterances === undefined ) { throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Nothing to update"); } const existing = await prisma.techStepTrainingSuggestion.findUnique({ where: { id } }); if (!existing) { throw new HttpError(404, ErrorCode.NOT_FOUND, `Training suggestion ${id} not found`); } const updated = await prisma.techStepTrainingSuggestion.update({ where: { id }, data: { ...(input.status !== undefined ? { status: input.status } : {}), ...(input.suggestedSynonyms !== undefined ? { suggestedSynonyms: input.suggestedSynonyms } : {}), ...(input.suggestedUtterances !== undefined ? { suggestedUtterances: input.suggestedUtterances } : {}), }, include: suggestionInclude, }); return toSuggestionView(updated); } catch (err) { throw err; } } /** Deduplicates while preserving first-seen order — for pooling synonyms/utterances across suggestions. */ function dedupe(values: string[]): string[] { return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))]; } /** Indents each entry as a Python list literal line (4-space, trailing comma) — the shape `training_data.py`'s blocks use. */ function pythonListBody(entries: string[]): string { return entries.map((entry) => ` ${JSON.stringify(entry)},`).join("\n"); } /** * Aggregates the synonyms/utterances of every suggestion matching * `techStepKey` + `locale` + `status` into a ready-to-paste * `training_data.py` block. **Read-only** — editing that Python file and * restarting the intent-service stay a manual maintainer step. */ export async function getTrainingDataSnippet( query: TrainingDataSnippetQuery, ): Promise { try { const rows = await prisma.techStepTrainingSuggestion.findMany({ where: { locale: query.locale, status: query.status, techStep: { key: query.techStepKey }, }, select: { suggestedSynonyms: true, suggestedUtterances: true }, }); const synonyms = dedupe(rows.flatMap((row) => row.suggestedSynonyms)); const utterances = dedupe(rows.flatMap((row) => row.suggestedUtterances)); const snippet = [ `# ${query.techStepKey} (${query.locale}) — ${rows.length} suggestion(s) "${query.status}"`, `"synonyms": [`, pythonListBody(synonyms), `],`, `"utterances": [`, pythonListBody(utterances), `],`, ] .filter((line) => line.length > 0) .join("\n"); return { techStepKey: query.techStepKey, locale: query.locale, status: query.status, suggestionCount: rows.length, synonyms, utterances, snippet, }; } catch (err) { throw err; } } /** Process-wide lock — the F1 gate + full backfill is heavy and must never run twice concurrently. */ let retrainInProgress = false; /** * Runs the training-corpus regression gate then, if it passes, backfills * every step and marks the given suggestion ids — the same three steps as * `scripts/retrain-tech-steps.ts`, callable from the admin UI. * * **Only meaningful after** a maintainer has hand-edited * `services/tech-step-intent-service/intent_service/training_data.py` **and * restarted that service** (it trains once at boot) — this endpoint can do * neither, and the admin UI states that prominently. * * A failed gate returns `gatePassed: false` with no backfill / no marking * (HTTP 200 — it's an expected outcome to show the operator, not an error). * * @throws {HttpError} `409 RETRAIN_ALREADY_RUNNING` if a retrain is already in flight. */ export async function runRetrain(input: RetrainRequestInput): Promise { if (retrainInProgress) { throw new HttpError( 409, ErrorCode.RETRAIN_ALREADY_RUNNING, "A retrain (F1 gate + backfill) is already running", ); } retrainInProgress = true; try { const { overall } = await runTechStepEvalSuite(); const gatePassed = overall.f1 >= MIN_OVERALL_F1; let backfilled: RetrainResultView["backfilled"] = null; const marked = { applied: 0, rejected: 0 }; if (gatePassed) { backfilled = await backfillTechSteps(); const appliedIds = input.appliedIds ?? []; const rejectedIds = input.rejectedIds ?? []; if (appliedIds.length > 0) { const { count } = await prisma.techStepTrainingSuggestion.updateMany({ where: { id: { in: appliedIds } }, data: { status: "applied" }, }); marked.applied = count; } if (rejectedIds.length > 0) { const { count } = await prisma.techStepTrainingSuggestion.updateMany({ where: { id: { in: rejectedIds } }, data: { status: "rejected" }, }); marked.rejected = count; } } return { f1: overall.f1, precision: overall.precision, recall: overall.recall, minF1: MIN_OVERALL_F1, gatePassed, backfilled, marked, }; } catch (err) { throw err; // see recipe.service.ts's equivalent catch comment } finally { retrainInProgress = false; } }