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>
106 lines
5.1 KiB
TypeScript
106 lines
5.1 KiB
TypeScript
import { z } from "zod";
|
|
|
|
// Shared between apps/api (server-side validation, source of truth) and
|
|
// apps/admin-web (client-side validation for instant feedback). Same
|
|
// rationale as schemas/auth.ts — one set of rules, French messages surfaced
|
|
// as-is in the admin login form.
|
|
|
|
/**
|
|
* Payload accepted by `POST /admin/auth/login`. Deliberately its own schema
|
|
* (not a re-export of `loginSchema`): the admin surface is a separate
|
|
* contract from the end-user one even though the shape currently matches.
|
|
*/
|
|
export const adminLoginSchema = z.object({
|
|
email: z.string().trim().toLowerCase().email("Email invalide"),
|
|
password: z.string().min(1, "Le mot de passe est requis"),
|
|
});
|
|
/** Inferred TS type for {@link adminLoginSchema}'s validated output. */
|
|
export type AdminLoginInput = z.infer<typeof adminLoginSchema>;
|
|
|
|
/**
|
|
* Query params for `GET /admin/metrics` — `?days=` bounds how far back the
|
|
* time-series go (and how many daily buckets they carry). Coerced from the
|
|
* query string; clamped to a sane window so a huge value can't make the
|
|
* dashboard scan the whole history.
|
|
*/
|
|
export const getMetricsSchema = z.object({
|
|
days: z.coerce.number().int().min(7).max(365).default(30),
|
|
});
|
|
/** Inferred TS type for {@link getMetricsSchema}'s validated output. */
|
|
export type GetMetricsInput = z.infer<typeof getMetricsSchema>;
|
|
|
|
/**
|
|
* Payload of `POST /internal/tech-steps/heartbeat` — `services/tech-step-llm-worker`
|
|
* (which has no inbound HTTP of its own) reporting that it's alive. Sent on
|
|
* `boot`, on every scheduler `tick`, and after each `job` (with that job's
|
|
* name, outcome and counts). The worker key is fixed server-side (only one
|
|
* worker exists), so it isn't in the payload.
|
|
*/
|
|
export const workerHeartbeatSchema = z.object({
|
|
event: z.enum(["boot", "tick", "job"]),
|
|
/** The job that just ran — present only when `event === "job"`. */
|
|
job: z.string().max(100).optional(),
|
|
/** Whether that job succeeded — present only when `event === "job"`. */
|
|
ok: z.boolean().optional(),
|
|
/** Small `{ label: number }` summary of that job (e.g. `{ suggestions: 3 }`). */
|
|
counts: z.record(z.string(), z.number()).optional(),
|
|
});
|
|
/** Inferred TS type for {@link workerHeartbeatSchema}'s validated output. */
|
|
export type WorkerHeartbeatInput = z.infer<typeof workerHeartbeatSchema>;
|
|
|
|
/** Statuses a `TechStepTrainingSuggestion` can be filtered by / set to. */
|
|
export const TRAINING_SUGGESTION_STATUSES = ["pending", "applied", "rejected"] as const;
|
|
/** One of {@link TRAINING_SUGGESTION_STATUSES}. */
|
|
export type TrainingSuggestionStatus = (typeof TRAINING_SUGGESTION_STATUSES)[number];
|
|
|
|
/** Query params for `GET /admin/tech-steps/suggestions` — every filter optional. */
|
|
export const listSuggestionsQuerySchema = z.object({
|
|
status: z.enum(TRAINING_SUGGESTION_STATUSES).optional(),
|
|
sourceType: z.enum(["correction", "llm_audit"]).optional(),
|
|
techStepKey: z.string().min(1).optional(),
|
|
locale: z.string().min(1).optional(),
|
|
});
|
|
/** Inferred TS type for {@link listSuggestionsQuerySchema}. */
|
|
export type ListSuggestionsQuery = z.infer<typeof listSuggestionsQuerySchema>;
|
|
|
|
/** Query params for `GET /admin/tech-steps/corrections`. `consumed`/`hasCorrectedTechStep` are tri-state (omitted = no filter). */
|
|
export const listCorrectionsQuerySchema = z.object({
|
|
consumed: z.enum(["true", "false"]).optional(),
|
|
hasCorrectedTechStep: z.enum(["true", "false"]).optional(),
|
|
});
|
|
/** Inferred TS type for {@link listCorrectionsQuerySchema}. */
|
|
export type ListCorrectionsQuery = z.infer<typeof listCorrectionsQuerySchema>;
|
|
|
|
/**
|
|
* Body of `PATCH /admin/tech-steps/suggestions/:id` — curate a suggestion
|
|
* before it feeds a `training_data.py` edit. Every field optional; at least
|
|
* one must be present (enforced service-side).
|
|
*/
|
|
export const updateTrainingSuggestionSchema = z.object({
|
|
status: z.enum(TRAINING_SUGGESTION_STATUSES).optional(),
|
|
suggestedSynonyms: z.array(z.string().min(1)).max(200).optional(),
|
|
suggestedUtterances: z.array(z.string().min(1)).max(200).optional(),
|
|
});
|
|
/** Inferred TS type for {@link updateTrainingSuggestionSchema}. */
|
|
export type UpdateTrainingSuggestionInput = z.infer<typeof updateTrainingSuggestionSchema>;
|
|
|
|
/**
|
|
* Body of `POST /admin/tech-steps/retrain` — runs the F1 regression gate
|
|
* then, if it passes, backfills every step and marks the given suggestion
|
|
* ids. Both id lists optional (an empty run just re-gates + backfills).
|
|
*/
|
|
export const retrainRequestSchema = z.object({
|
|
appliedIds: z.array(z.number().int().positive()).max(500).optional(),
|
|
rejectedIds: z.array(z.number().int().positive()).max(500).optional(),
|
|
});
|
|
/** Inferred TS type for {@link retrainRequestSchema}. */
|
|
export type RetrainRequestInput = z.infer<typeof retrainRequestSchema>;
|
|
|
|
/** Query params for `GET /admin/tech-steps/training-data-snippet`. */
|
|
export const trainingDataSnippetQuerySchema = z.object({
|
|
techStepKey: z.string().min(1),
|
|
locale: z.string().min(1).default("fr"),
|
|
status: z.enum(TRAINING_SUGGESTION_STATUSES).default("applied"),
|
|
});
|
|
/** Inferred TS type for {@link trainingDataSnippetQuerySchema}. */
|
|
export type TrainingDataSnippetQuery = z.infer<typeof trainingDataSnippetQuerySchema>;
|