PR 5 (derniere) du chantier admin. Remplace le duo CLI list-pending-training-suggestions.ts / retrain-tech-steps.ts par une UI. API (admin-tech-steps.service.ts, routes /admin/tech-steps/*, requireAdmin) : - GET /suggestions : TechStepTrainingSuggestion filtrees, groupees par technique, enrichies du contexte de la correction source. - GET /corrections : corrections brutes filtrables, incluant les suppressions correctedTechStepId:null invisibles ailleurs. - PATCH /suggestions/:id : edite synonymes/phrases et/ou status. - GET /training-data-snippet : bloc training_data.py a coller (lecture seule). - POST /retrain : runTechStepEvalSuite() (gate F1 vs MIN_OVERALL_F1) puis si passe backfillTechSteps() + marquage applied/rejected. Verrou memoire -> 409 RETRAIN_ALREADY_RUNNING. Gate echoue -> 200 gatePassed:false. N'edite pas le .py ni ne redemarre l'intent-service (manuel). Shared : nouveau ErrorCode RETRAIN_ALREADY_RUNNING (4023, + cle i18n apps/web), schemas (list*/update*/retrain*/snippet), types (TrainingSuggestion*/Correction*/RetrainResultView...). Front : CorrectionsPage (onglets Suggestions / Corrections brutes, bandeau caveat permanent, cartes editables + Appliquer/Rejeter, panneau snippet, panneau gate F1). Logique pure corrections.ts. i18n admin.corrections.*. AdminApiClient : 5 methodes. Tests : Mocha admin-tech-steps.test.ts (401 partout, groupement+filtre, PATCH 400/404/ok, corrections incluant removals, snippet, retrain shape + 409 concurrent) ; Cypress corrections.cy.ts (4 verts). Admin-web Cypress 13/13. specs/backend-architecture.md : section tri + retrain. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
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<TrainingSuggestionGroupView[]> {
|
|
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<string, TrainingSuggestionAdminView[]>();
|
|
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<CorrectionAdminView[]> {
|
|
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<TrainingSuggestionAdminView> {
|
|
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<TrainingDataSnippetView> {
|
|
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<RetrainResultView> {
|
|
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;
|
|
}
|
|
}
|