diff --git a/apps/admin-web/cypress/e2e/corrections.cy.ts b/apps/admin-web/cypress/e2e/corrections.cy.ts new file mode 100644 index 0000000..e38cd98 --- /dev/null +++ b/apps/admin-web/cypress/e2e/corrections.cy.ts @@ -0,0 +1,159 @@ +// Mocks the admin API via cy.intercept — no live backend. + +const adminBody = { + id: 1, + email: "ops@example.com", + name: "Ops", + createdAt: "2026-08-01T00:00:00.000Z", + lastLoginAt: "2026-08-28T09:00:00.000Z", +}; + +function suggestionGroups() { + return [ + { + techStepKey: "simmer", + suggestions: [ + { + id: 11, + techStepKey: "simmer", + locale: "fr", + suggestedSynonyms: ["frémir"], + suggestedUtterances: ["laisser cuire tout doucement"], + sourceType: "correction", + status: "pending", + createdAt: "2026-08-20T00:00:00.000Z", + sourceCorrection: { + id: 5, + recipeId: 2, + stepId: 7, + clauseText: "faire mijoter la sauce", + previousTechStepKey: "cook", + correctedTechStepKey: "simmer", + }, + }, + ], + }, + ]; +} + +function corrections() { + return [ + { + id: 5, + recipeId: 2, + stepId: 7, + stepDescription: "Faire mijoter la sauce 20 min.", + clauseText: "faire mijoter la sauce", + start: 0, + end: 21, + previousTechStepKey: "cook", + correctedTechStepKey: "simmer", + createdAt: "2026-08-20T00:00:00.000Z", + consumedAt: null, + }, + { + id: 6, + recipeId: 3, + stepId: 9, + stepDescription: "Réserver au frais.", + clauseText: "Réserver au frais", + start: 0, + end: 17, + previousTechStepKey: "setAside", + correctedTechStepKey: null, + createdAt: "2026-08-19T00:00:00.000Z", + consumedAt: null, + }, + ]; +} + +describe("Admin corrections triage", () => { + beforeEach(() => { + cy.viewport(1400, 1000); + cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); + cy.intercept("GET", "**/admin/tech-steps/suggestions*", { + statusCode: 200, + body: suggestionGroups(), + }).as("getSuggestions"); + cy.intercept("GET", "**/admin/tech-steps/corrections*", { + statusCode: 200, + body: corrections(), + }).as("getCorrections"); + }); + + it("shows the caveat, groups suggestions by technique, and applies one", () => { + cy.intercept("PATCH", "**/admin/tech-steps/suggestions/11", { + statusCode: 200, + body: { ...suggestionGroups()[0].suggestions[0], status: "applied" }, + }).as("patch"); + + cy.visit("/corrections"); + cy.wait("@getSuggestions"); + + cy.contains(".corrections-caveat", "training_data.py").should("be.visible"); + cy.contains(".suggestion-group h2", "simmer").should("be.visible"); + cy.contains(".suggestion-card", "faire mijoter la sauce").should( + "contain.text", + "cook → simmer", + ); + + cy.contains(".suggestion-card button", "Appliquer").click(); + cy.wait("@patch").its("request.body").should("deep.equal", { status: "applied" }); + }); + + it("generates a training_data.py snippet", () => { + cy.intercept("GET", "**/admin/tech-steps/training-data-snippet*", { + statusCode: 200, + body: { + techStepKey: "simmer", + locale: "fr", + status: "applied", + suggestionCount: 2, + synonyms: ["frémir", "réduire"], + utterances: [], + snippet: + '# simmer (fr) — 2 suggestion(s) "applied"\n"synonyms": [\n "frémir",\n "réduire",\n],', + }, + }).as("getSnippet"); + + cy.visit("/corrections"); + cy.get(".corrections-panel input").type("simmer"); + cy.contains(".corrections-panel button", "Générer").click(); + cy.wait("@getSnippet"); + cy.get(".corrections-snippet").should("contain.value", '"synonyms": ['); + }); + + it("runs the F1 gate and shows the result", () => { + cy.intercept("POST", "**/admin/tech-steps/retrain", { + statusCode: 200, + body: { + f1: 0.83, + precision: 0.8, + recall: 0.86, + minF1: 0.8, + gatePassed: true, + backfilled: { total: 120, changed: 4 }, + marked: { applied: 0, rejected: 0 }, + }, + }).as("retrain"); + + cy.visit("/corrections"); + cy.contains(".corrections-panel--retrain button", "Lancer").click(); + cy.wait("@retrain"); + cy.contains(".retrain-result", "F1 0.830") + .should("have.class", "retrain-result--ok") + .and("contain.text", "4/120"); + }); + + it("lists raw corrections including the removals, on the second tab", () => { + cy.visit("/corrections"); + cy.contains(".corrections-tabs button", "Corrections brutes").click(); + cy.wait("@getCorrections"); + + cy.get(".corrections-table tbody tr").should("have.length", 2); + cy.contains(".corrections-table tr", "Réserver au frais").should( + "contain.text", + "setAside → ∅", + ); + }); +}); diff --git a/apps/admin-web/src/api/client.ts b/apps/admin-web/src/api/client.ts index 288fc68..b81b7a2 100644 --- a/apps/admin-web/src/api/client.ts +++ b/apps/admin-web/src/api/client.ts @@ -2,11 +2,26 @@ import { type AdminLoginInput, type AdminUserView, type ApiErrorResponse, + type CorrectionAdminView, ErrorCode, type MetricsView, type MonitoringView, + type RetrainRequestInput, + type RetrainResultView, + type TrainingDataSnippetView, + type TrainingSuggestionAdminView, + type TrainingSuggestionGroupView, + type UpdateTrainingSuggestionInput, } from "@batch-cooking/shared"; +/** Builds a `?a=b&c=d` string from defined values only. */ +function query(params: Record): string { + const entries = Object.entries(params).filter( + (entry): entry is [string, string] => entry[1] !== undefined && entry[1] !== "", + ); + return entries.length === 0 ? "" : `?${new URLSearchParams(entries).toString()}`; +} + /** * Base URL of the admin API surface, configurable via `VITE_ADMIN_API_URL` * (see `.env.example`). Defaults to `""` (same origin) — correct behind a @@ -102,6 +117,52 @@ export class AdminApiClient { public getMonitoring(): Promise { return this._request("/admin/monitoring"); } + + /** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */ + public getSuggestions(filters: { + status?: string; + sourceType?: string; + techStepKey?: string; + locale?: string; + }): Promise { + return this._request(`/admin/tech-steps/suggestions${query(filters)}`); + } + + /** Raw user corrections, including the "no technique here" removals. */ + public getCorrections(filters: { + consumed?: string; + hasCorrectedTechStep?: string; + }): Promise { + return this._request(`/admin/tech-steps/corrections${query(filters)}`); + } + + /** Edits a suggestion's proposed synonyms/utterances and/or its status. */ + public updateSuggestion( + id: number, + body: UpdateTrainingSuggestionInput, + ): Promise { + return this._request(`/admin/tech-steps/suggestions/${id}`, { + method: "PATCH", + body: JSON.stringify(body), + }); + } + + /** The ready-to-paste `training_data.py` block aggregating suggestions for one technique/locale/status. */ + public getTrainingDataSnippet(params: { + techStepKey: string; + locale?: string; + status?: string; + }): Promise { + return this._request(`/admin/tech-steps/training-data-snippet${query(params)}`); + } + + /** Runs the F1 gate + backfill (+ marks suggestion ids). Rejects with `RETRAIN_ALREADY_RUNNING` if one is in flight. */ + public retrain(body: RetrainRequestInput): Promise { + return this._request("/admin/tech-steps/retrain", { + method: "POST", + body: JSON.stringify(body), + }); + } } /** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ diff --git a/apps/admin-web/src/locales/fr/translation.json b/apps/admin-web/src/locales/fr/translation.json index 11bc033..bcf8f94 100644 --- a/apps/admin-web/src/locales/fr/translation.json +++ b/apps/admin-web/src/locales/fr/translation.json @@ -5,6 +5,7 @@ "NOT_AUTHENTICATED": "Vous devez être connecté", "NOT_FOUND": "Ressource introuvable", "TECH_STEP_NOT_FOUND": "Cette technique n'existe pas", + "RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours", "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard" }, "admin": { @@ -81,7 +82,47 @@ }, "corrections": { "title": "Corrections", - "lead": "Tri des corrections utilisateur pour le ré-entraînement NLP." + "lead": "Tri des corrections utilisateur pour le ré-entraînement NLP.", + "caveat": "Le gate F1 + backfill n'a de sens qu'APRÈS avoir édité training_data.py à la main et redémarré le service NLP (il ne s'entraîne qu'au démarrage). Cet écran ne peut faire ni l'un ni l'autre.", + "noSuggestions": "Aucune suggestion pour ces filtres.", + "synonyms": "Synonymes proposés (un par ligne)", + "utterances": "Phrases proposées (une par ligne)", + "save": "Enregistrer", + "apply": "Appliquer", + "reject": "Rejeter", + "tab": { + "suggestions": "Suggestions", + "corrections": "Corrections brutes" + }, + "filter": { + "status": "Statut", + "source": "Source", + "consumed": "Consommée", + "hasCorrected": "Technique corrigée", + "any": "Toutes", + "yes": "Oui", + "no": "Non" + }, + "snippet": { + "title": "Snippet training_data.py", + "help": "Agrège les synonymes/phrases des suggestions « applied » d'une technique, au format à coller dans training_data.py.", + "keyPlaceholder": "clé de technique (ex. simmer)", + "generate": "Générer" + }, + "retrain": { + "title": "Gate F1 + backfill", + "help": "Lance l'évaluation de régression F1 puis, si elle passe, recalcule les techniques de toutes les étapes.", + "run": "Lancer", + "running": "En cours…", + "passed": "OK — {{changed}}/{{total}} étape(s) recalculée(s)", + "failed": "Échec du gate — aucun backfill" + }, + "col": { + "clause": "Clause", + "change": "Changement", + "created": "Créée", + "consumed": "Consommée" + } } } } diff --git a/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx b/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx index a197e9d..02c8c59 100644 --- a/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx +++ b/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx @@ -1,19 +1,395 @@ +import { + type CorrectionAdminView, + ErrorCode, + type RetrainResultView, + type TrainingSuggestionAdminView, + type TrainingSuggestionGroupView, +} from "@batch-cooking/shared"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { ApiError, adminApiClient } from "../../api/client"; +import { errorMessageService } from "../../services/error-message.service"; import "../admin-page.scss"; +import "./corrections-page.scss"; +import { linesToList, listsDiffer, listToLines } from "./corrections"; + +type Tab = "suggestions" | "corrections"; /** - * Tech-step correction triage — review `TechStepTrainingSuggestion` / - * `StepTechStepCorrection`, mark applied/rejected, generate the - * `training_data.py` snippet, and trigger the F1 gate + backfill. Placeholder - * until PR 5 (correction triage + retrain). + * Tech-step correction triage. Two tabs — curated `TrainingSuggestion`s and + * raw `StepTechStepCorrection`s — plus the snippet generator and the F1 + * gate + backfill trigger. Replaces the `list-pending-training-suggestions.ts` + * / `retrain-tech-steps.ts` CLI pair. */ export function CorrectionsPage() { const { t } = useTranslation(); + const [tab, setTab] = useState("suggestions"); + return (

{t("admin.corrections.title")}

{t("admin.corrections.lead")}

-

{t("admin.common.comingSoon")}

+ +

{t("admin.corrections.caveat")}

+ +
+ + +
+ + {tab === "suggestions" ? : } +
+ ); +} + +// --- Suggestions tab ------------------------------------------------------- + +type SuggestionsState = + | { status: "loading" } + | { status: "loaded"; groups: TrainingSuggestionGroupView[] } + | { status: "error" }; + +function SuggestionsTab() { + const { t } = useTranslation(); + const [statusFilter, setStatusFilter] = useState(""); + const [sourceFilter, setSourceFilter] = useState(""); + const [state, setState] = useState({ status: "loading" }); + + const load = useCallback(() => { + setState({ status: "loading" }); + adminApiClient + .getSuggestions({ + status: statusFilter || undefined, + sourceType: sourceFilter || undefined, + }) + .then((groups) => setState({ status: "loaded", groups })) + .catch(() => setState({ status: "error" })); + }, [statusFilter, sourceFilter]); + + useEffect(load, [load]); + + return ( +
+ + + +
+ + +
+ + {state.status === "loading" && ( +

{t("admin.common.loading")}

+ )} + {state.status === "error" && ( +

{t("admin.common.loadError")}

+ )} + {state.status === "loaded" && state.groups.length === 0 && ( +

{t("admin.corrections.noSuggestions")}

+ )} + {state.status === "loaded" && + state.groups.map((group) => ( +
+

{group.techStepKey}

+ {group.suggestions.map((suggestion) => ( + + ))} +
+ ))} +
+ ); +} + +function SuggestionCard({ + suggestion, + onMutated, +}: { + suggestion: TrainingSuggestionAdminView; + onMutated: () => void; +}) { + const { t } = useTranslation(); + const [synonyms, setSynonyms] = useState(listToLines(suggestion.suggestedSynonyms)); + const [utterances, setUtterances] = useState(listToLines(suggestion.suggestedUtterances)); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const dirty = + listsDiffer(linesToList(synonyms), suggestion.suggestedSynonyms) || + listsDiffer(linesToList(utterances), suggestion.suggestedUtterances); + + async function patch(body: Parameters[1]) { + setBusy(true); + setError(null); + try { + await adminApiClient.updateSuggestion(suggestion.id, body); + onMutated(); + } catch (err) { + setError( + errorMessageService.getLabel(err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR), + ); + } finally { + setBusy(false); + } + } + + return ( +
+
+ + #{suggestion.id} · {suggestion.locale} · {suggestion.sourceType} ·{" "} + {suggestion.status} + +
+ + {suggestion.sourceCorrection && ( +

+ + « {suggestion.sourceCorrection.clauseText} » + {" "} + {suggestion.sourceCorrection.previousTechStepKey ?? "∅"} →{" "} + {suggestion.sourceCorrection.correctedTechStepKey ?? "∅"} +

+ )} + +