feat(admin): tri des corrections + declenchement du gate F1/backfill
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>
This commit is contained in:
parent
29432e5bf2
commit
fcaecb4e07
15 changed files with 1801 additions and 7 deletions
159
apps/admin-web/cypress/e2e/corrections.cy.ts
Normal file
159
apps/admin-web/cypress/e2e/corrections.cy.ts
Normal file
|
|
@ -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 → ∅",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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, string | undefined>): 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<MonitoringView> {
|
||||
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<TrainingSuggestionGroupView[]> {
|
||||
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<CorrectionAdminView[]> {
|
||||
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<TrainingSuggestionAdminView> {
|
||||
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<TrainingDataSnippetView> {
|
||||
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<RetrainResultView> {
|
||||
return this._request("/admin/tech-steps/retrain", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Tab>("suggestions");
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<h1 className="admin-page__title">{t("admin.corrections.title")}</h1>
|
||||
<p className="admin-page__lead">{t("admin.corrections.lead")}</p>
|
||||
<p className="admin-page__placeholder">{t("admin.common.comingSoon")}</p>
|
||||
|
||||
<p className="corrections-caveat">{t("admin.corrections.caveat")}</p>
|
||||
|
||||
<div className="corrections-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={tab === "suggestions" ? "active" : undefined}
|
||||
onClick={() => setTab("suggestions")}
|
||||
>
|
||||
{t("admin.corrections.tab.suggestions")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tab === "corrections" ? "active" : undefined}
|
||||
onClick={() => setTab("corrections")}
|
||||
>
|
||||
{t("admin.corrections.tab.corrections")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "suggestions" ? <SuggestionsTab /> : <CorrectionsTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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<SuggestionsState>({ 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 (
|
||||
<div className="suggestions-tab">
|
||||
<RetrainPanel />
|
||||
<SnippetPanel />
|
||||
|
||||
<div className="corrections-filters">
|
||||
<label>
|
||||
{t("admin.corrections.filter.status")}
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="">{t("admin.corrections.filter.any")}</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="applied">applied</option>
|
||||
<option value="rejected">rejected</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t("admin.corrections.filter.source")}
|
||||
<select value={sourceFilter} onChange={(e) => setSourceFilter(e.target.value)}>
|
||||
<option value="">{t("admin.corrections.filter.any")}</option>
|
||||
<option value="correction">correction</option>
|
||||
<option value="llm_audit">llm_audit</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{state.status === "loading" && (
|
||||
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
|
||||
)}
|
||||
{state.status === "loaded" && state.groups.length === 0 && (
|
||||
<p className="admin-page__placeholder">{t("admin.corrections.noSuggestions")}</p>
|
||||
)}
|
||||
{state.status === "loaded" &&
|
||||
state.groups.map((group) => (
|
||||
<section key={group.techStepKey} className="suggestion-group">
|
||||
<h2>{group.techStepKey}</h2>
|
||||
{group.suggestions.map((suggestion) => (
|
||||
<SuggestionCard key={suggestion.id} suggestion={suggestion} onMutated={load} />
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
|
||||
const dirty =
|
||||
listsDiffer(linesToList(synonyms), suggestion.suggestedSynonyms) ||
|
||||
listsDiffer(linesToList(utterances), suggestion.suggestedUtterances);
|
||||
|
||||
async function patch(body: Parameters<typeof adminApiClient.updateSuggestion>[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 (
|
||||
<article className={`suggestion-card suggestion-card--${suggestion.status}`}>
|
||||
<header className="suggestion-card__head">
|
||||
<span className="suggestion-card__meta">
|
||||
#{suggestion.id} · {suggestion.locale} · {suggestion.sourceType} ·{" "}
|
||||
<strong>{suggestion.status}</strong>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{suggestion.sourceCorrection && (
|
||||
<p className="suggestion-card__source">
|
||||
<span className="suggestion-card__clause">
|
||||
« {suggestion.sourceCorrection.clauseText} »
|
||||
</span>{" "}
|
||||
{suggestion.sourceCorrection.previousTechStepKey ?? "∅"} →{" "}
|
||||
{suggestion.sourceCorrection.correctedTechStepKey ?? "∅"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label>
|
||||
{t("admin.corrections.synonyms")}
|
||||
<textarea value={synonyms} onChange={(e) => setSynonyms(e.target.value)} rows={3} />
|
||||
</label>
|
||||
<label>
|
||||
{t("admin.corrections.utterances")}
|
||||
<textarea value={utterances} onChange={(e) => setUtterances(e.target.value)} rows={3} />
|
||||
</label>
|
||||
|
||||
{error && <p className="suggestion-card__error">{error}</p>}
|
||||
|
||||
<div className="suggestion-card__actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !dirty}
|
||||
onClick={() =>
|
||||
patch({
|
||||
suggestedSynonyms: linesToList(synonyms),
|
||||
suggestedUtterances: linesToList(utterances),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("admin.corrections.save")}
|
||||
</button>
|
||||
<button type="button" disabled={busy} onClick={() => patch({ status: "applied" })}>
|
||||
{t("admin.corrections.apply")}
|
||||
</button>
|
||||
<button type="button" disabled={busy} onClick={() => patch({ status: "rejected" })}>
|
||||
{t("admin.corrections.reject")}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Snippet + retrain panels ------------------------------------------------
|
||||
|
||||
function SnippetPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [techStepKey, setTechStepKey] = useState("");
|
||||
const [snippet, setSnippet] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function generate() {
|
||||
setError(null);
|
||||
setSnippet(null);
|
||||
try {
|
||||
const result = await adminApiClient.getTrainingDataSnippet({
|
||||
techStepKey: techStepKey.trim(),
|
||||
status: "applied",
|
||||
});
|
||||
setSnippet(result.snippet);
|
||||
} catch (err) {
|
||||
setError(
|
||||
errorMessageService.getLabel(err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="corrections-panel">
|
||||
<h2>{t("admin.corrections.snippet.title")}</h2>
|
||||
<p>{t("admin.corrections.snippet.help")}</p>
|
||||
<div className="corrections-panel__row">
|
||||
<input
|
||||
value={techStepKey}
|
||||
onChange={(e) => setTechStepKey(e.target.value)}
|
||||
placeholder={t("admin.corrections.snippet.keyPlaceholder")}
|
||||
/>
|
||||
<button type="button" disabled={techStepKey.trim().length === 0} onClick={generate}>
|
||||
{t("admin.corrections.snippet.generate")}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="suggestion-card__error">{error}</p>}
|
||||
{snippet !== null && (
|
||||
<textarea className="corrections-snippet" readOnly rows={10} value={snippet} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RetrainPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<RetrainResultView | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function run() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
setResult(await adminApiClient.retrain({}));
|
||||
} catch (err) {
|
||||
setError(
|
||||
errorMessageService.getLabel(err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="corrections-panel corrections-panel--retrain">
|
||||
<h2>{t("admin.corrections.retrain.title")}</h2>
|
||||
<p>{t("admin.corrections.retrain.help")}</p>
|
||||
<button type="button" disabled={busy} onClick={run}>
|
||||
{busy ? t("admin.corrections.retrain.running") : t("admin.corrections.retrain.run")}
|
||||
</button>
|
||||
{error && <p className="suggestion-card__error">{error}</p>}
|
||||
{result && (
|
||||
<p className={`retrain-result retrain-result--${result.gatePassed ? "ok" : "fail"}`}>
|
||||
F1 {result.f1.toFixed(3)} / {result.minF1} —{" "}
|
||||
{result.gatePassed
|
||||
? t("admin.corrections.retrain.passed", {
|
||||
total: result.backfilled?.total ?? 0,
|
||||
changed: result.backfilled?.changed ?? 0,
|
||||
})
|
||||
: t("admin.corrections.retrain.failed")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Raw corrections tab --------------------------------------------------
|
||||
|
||||
type CorrectionsState =
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; rows: CorrectionAdminView[] }
|
||||
| { status: "error" };
|
||||
|
||||
function CorrectionsTab() {
|
||||
const { t } = useTranslation();
|
||||
const [consumed, setConsumed] = useState("");
|
||||
const [hasCorrected, setHasCorrected] = useState("");
|
||||
const [state, setState] = useState<CorrectionsState>({ status: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ status: "loading" });
|
||||
adminApiClient
|
||||
.getCorrections({
|
||||
consumed: consumed || undefined,
|
||||
hasCorrectedTechStep: hasCorrected || undefined,
|
||||
})
|
||||
.then((rows) => {
|
||||
if (!cancelled) setState({ status: "loaded", rows });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setState({ status: "error" });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [consumed, hasCorrected]);
|
||||
|
||||
return (
|
||||
<div className="corrections-tab">
|
||||
<div className="corrections-filters">
|
||||
<label>
|
||||
{t("admin.corrections.filter.consumed")}
|
||||
<select value={consumed} onChange={(e) => setConsumed(e.target.value)}>
|
||||
<option value="">{t("admin.corrections.filter.any")}</option>
|
||||
<option value="true">{t("admin.corrections.filter.yes")}</option>
|
||||
<option value="false">{t("admin.corrections.filter.no")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t("admin.corrections.filter.hasCorrected")}
|
||||
<select value={hasCorrected} onChange={(e) => setHasCorrected(e.target.value)}>
|
||||
<option value="">{t("admin.corrections.filter.any")}</option>
|
||||
<option value="true">{t("admin.corrections.filter.yes")}</option>
|
||||
<option value="false">{t("admin.corrections.filter.no")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{state.status === "loading" && (
|
||||
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
|
||||
)}
|
||||
{state.status === "loaded" && (
|
||||
<div className="corrections-table-wrap">
|
||||
<table className="corrections-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>{t("admin.corrections.col.clause")}</th>
|
||||
<th>{t("admin.corrections.col.change")}</th>
|
||||
<th>{t("admin.corrections.col.created")}</th>
|
||||
<th>{t("admin.corrections.col.consumed")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{row.id}</td>
|
||||
<td className="corrections-table__clause">« {row.clauseText} »</td>
|
||||
<td>
|
||||
{row.previousTechStepKey ?? "∅"} → {row.correctedTechStepKey ?? "∅"}
|
||||
</td>
|
||||
<td>{new Date(row.createdAt).toLocaleDateString("fr-FR")}</td>
|
||||
<td>{row.consumedAt ? "✓" : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
266
apps/admin-web/src/pages/corrections/corrections-page.scss
Normal file
266
apps/admin-web/src/pages/corrections/corrections-page.scss
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
// =============================================================================
|
||||
// CorrectionsPage — a caveat banner, two tabs, filter rows, suggestion cards
|
||||
// with editable textareas, the snippet + retrain panels, and the raw
|
||||
// corrections table.
|
||||
// =============================================================================
|
||||
|
||||
.corrections-caveat {
|
||||
margin: 0 0 var(--space-lg);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-left: 4px solid var(--color-warning);
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface));
|
||||
border-radius: var(--radius-base);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.corrections-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
margin-bottom: var(--space-lg);
|
||||
|
||||
button {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.corrections-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
select {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-family: var(--font-body);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.corrections-panel {
|
||||
margin-bottom: var(--space-lg);
|
||||
padding: var(--space-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-md);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 var(--space-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
&--retrain {
|
||||
border-left: 4px solid var(--color-accent);
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-family: var(--font-body);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
color: var(--color-surface);
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-base);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.corrections-snippet {
|
||||
width: 100%;
|
||||
margin-top: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-surface-alt);
|
||||
color: var(--color-text);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.retrain-result {
|
||||
margin-top: var(--space-sm);
|
||||
font-weight: 600;
|
||||
&--ok {
|
||||
color: var(--color-success);
|
||||
}
|
||||
&--fail {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-group {
|
||||
margin-bottom: var(--space-lg);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-md);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-card {
|
||||
margin-bottom: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 4px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
&--applied {
|
||||
border-left-color: var(--color-success);
|
||||
}
|
||||
&--rejected {
|
||||
border-left-color: var(--color-error);
|
||||
}
|
||||
&--pending {
|
||||
border-left-color: var(--color-warning);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
&__source {
|
||||
margin: var(--space-xs) 0 var(--space-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
&__clause {
|
||||
font-style: italic;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
margin-top: 0.15rem;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin: 0 0 var(--space-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
|
||||
button {
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.corrections-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.corrections-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__clause {
|
||||
font-style: italic;
|
||||
max-width: 28rem;
|
||||
}
|
||||
}
|
||||
23
apps/admin-web/src/pages/corrections/corrections.ts
Normal file
23
apps/admin-web/src/pages/corrections/corrections.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Pure helpers for `CorrectionsPage` — the editable synonym/utterance
|
||||
* fields are one-per-line textareas, so these convert between that and the
|
||||
* `string[]` the API wants. Kept out of the `.tsx` per repo convention.
|
||||
*/
|
||||
|
||||
/** Textarea value (one entry per line) → trimmed, non-empty `string[]`. */
|
||||
export function linesToList(text: string): string[] {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
/** `string[]` → textarea value (one entry per line). */
|
||||
export function listToLines(values: string[]): string {
|
||||
return values.join("\n");
|
||||
}
|
||||
|
||||
/** True when two string lists differ (order-sensitive) — gates the "save" button. */
|
||||
export function listsDiffer(a: string[], b: string[]): boolean {
|
||||
return a.length !== b.length || a.some((value, i) => value !== b[i]);
|
||||
}
|
||||
80
apps/api/src/modules/admin/admin-tech-steps.routes.ts
Normal file
80
apps/api/src/modules/admin/admin-tech-steps.routes.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import {
|
||||
ErrorCode,
|
||||
listCorrectionsQuerySchema,
|
||||
listSuggestionsQuerySchema,
|
||||
retrainRequestSchema,
|
||||
trainingDataSnippetQuerySchema,
|
||||
updateTrainingSuggestionSchema,
|
||||
} from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
||||
import {
|
||||
getTrainingDataSnippet,
|
||||
listCorrections,
|
||||
listSuggestions,
|
||||
runRetrain,
|
||||
updateSuggestion,
|
||||
} from "./admin-tech-steps.service.js";
|
||||
|
||||
/** Router mounted at `/admin/tech-steps` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. Correction/suggestion triage + the retrain trigger. */
|
||||
export const adminTechStepsRouter = Router();
|
||||
|
||||
adminTechStepsRouter.get(
|
||||
"/suggestions",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (req, res) => {
|
||||
res.status(200).json(await listSuggestions(listSuggestionsQuerySchema.parse(req.query)));
|
||||
}),
|
||||
);
|
||||
|
||||
adminTechStepsRouter.get(
|
||||
"/corrections",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (req, res) => {
|
||||
res.status(200).json(await listCorrections(listCorrectionsQuerySchema.parse(req.query)));
|
||||
}),
|
||||
);
|
||||
|
||||
adminTechStepsRouter.get(
|
||||
"/training-data-snippet",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (req, res) => {
|
||||
res
|
||||
.status(200)
|
||||
.json(await getTrainingDataSnippet(trainingDataSnippetQuerySchema.parse(req.query)));
|
||||
}),
|
||||
);
|
||||
|
||||
adminTechStepsRouter.patch(
|
||||
"/suggestions/:id",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
`Not a valid suggestion id: ${req.params.id}`,
|
||||
);
|
||||
}
|
||||
const input = updateTrainingSuggestionSchema.parse(req.body);
|
||||
res.status(200).json(await updateSuggestion(id, input));
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Runs the F1 regression gate then (if it passes) the full step backfill,
|
||||
* and marks the given suggestion ids — see {@link runRetrain}. Long-running
|
||||
* and process-locked: `409 RETRAIN_ALREADY_RUNNING` if one is already
|
||||
* underway.
|
||||
*/
|
||||
adminTechStepsRouter.post(
|
||||
"/retrain",
|
||||
requireAdmin,
|
||||
wrapAsyncHandler(async (req, res) => {
|
||||
const input = retrainRequestSchema.parse(req.body);
|
||||
res.status(200).json(await runRetrain(input));
|
||||
}),
|
||||
);
|
||||
325
apps/api/src/modules/admin/admin-tech-steps.service.ts
Normal file
325
apps/api/src/modules/admin/admin-tech-steps.service.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { Router } from "express";
|
|||
import { adminAuthRouter } from "./admin-auth.routes.js";
|
||||
import { adminMetricsRouter } from "./admin-metrics.routes.js";
|
||||
import { adminMonitoringRouter } from "./admin-monitoring.routes.js";
|
||||
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
|
||||
|
||||
/**
|
||||
* Aggregator for the admin application's API surface, mounted at `/admin`
|
||||
|
|
@ -15,3 +16,4 @@ export const adminRouter = Router();
|
|||
adminRouter.use("/auth", adminAuthRouter);
|
||||
adminRouter.use("/metrics", adminMetricsRouter);
|
||||
adminRouter.use("/monitoring", adminMonitoringRouter);
|
||||
adminRouter.use("/tech-steps", adminTechStepsRouter);
|
||||
|
|
|
|||
298
apps/api/test/admin-tech-steps.test.ts
Normal file
298
apps/api/test/admin-tech-steps.test.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
import { ErrorCode } from "@batch-cooking/shared";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { expect } from "chai";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app.js";
|
||||
import { env } from "../src/config/env.js";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
async function seedAdmin(): Promise<{ email: string; password: string }> {
|
||||
const email = faker.internet.email().toLowerCase();
|
||||
const password = faker.internet.password({ length: 16 });
|
||||
await prisma.adminUser.create({
|
||||
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
|
||||
});
|
||||
return { email, password };
|
||||
}
|
||||
|
||||
async function techStepId(key: string): Promise<number> {
|
||||
return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id;
|
||||
}
|
||||
|
||||
/** A recipe + one step + one correction on it, optionally already turned into a suggestion. */
|
||||
async function seedCorrectionAndSuggestion(options: {
|
||||
clause: string;
|
||||
correctedKey: string | null;
|
||||
withSuggestion?: { status: string; synonyms: string[] };
|
||||
}) {
|
||||
const author = await prisma.userProfile.create({
|
||||
data: {
|
||||
firstName: "T",
|
||||
lastName: "A",
|
||||
email: `${faker.string.uuid()}@example.test`,
|
||||
passwordHash: "x",
|
||||
},
|
||||
});
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: {
|
||||
name: "R",
|
||||
authorId: author.id,
|
||||
portions: 4,
|
||||
steps: { create: [{ description: options.clause, order: 0 }] },
|
||||
},
|
||||
include: { steps: true },
|
||||
});
|
||||
const step = recipe.steps[0];
|
||||
if (!step) throw new Error("expected a step");
|
||||
|
||||
const correctedKey = options.correctedKey;
|
||||
const correction = await prisma.stepTechStepCorrection.create({
|
||||
data: {
|
||||
stepId: step.id,
|
||||
correctorId: author.id,
|
||||
start: 0,
|
||||
end: options.clause.length,
|
||||
previousTechStepId: null,
|
||||
correctedTechStepId: correctedKey === null ? null : await techStepId(correctedKey),
|
||||
},
|
||||
});
|
||||
|
||||
let suggestion: { id: number } | null = null;
|
||||
if (options.withSuggestion && correctedKey !== null) {
|
||||
suggestion = await prisma.techStepTrainingSuggestion.create({
|
||||
data: {
|
||||
techStepId: await techStepId(correctedKey),
|
||||
locale: "fr",
|
||||
suggestedSynonyms: options.withSuggestion.synonyms,
|
||||
suggestedUtterances: [],
|
||||
sourceType: "correction",
|
||||
sourceCorrectionId: correction.id,
|
||||
status: options.withSuggestion.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { recipeId: recipe.id, stepId: step.id, correctionId: correction.id, suggestion };
|
||||
}
|
||||
|
||||
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
|
||||
|
||||
describe("Admin tech-steps triage", () => {
|
||||
const app = createApp();
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
async function adminAgent() {
|
||||
const { email, password } = await seedAdmin();
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/admin/auth/login").send({ email, password });
|
||||
return agent;
|
||||
}
|
||||
|
||||
it("rejects every route without an admin session", async () => {
|
||||
for (const path of [
|
||||
"/admin/tech-steps/suggestions",
|
||||
"/admin/tech-steps/corrections",
|
||||
"/admin/tech-steps/training-data-snippet?techStepKey=simmer",
|
||||
]) {
|
||||
const res = await request(app).get(path);
|
||||
expect(res.status, path).to.equal(401);
|
||||
}
|
||||
const post = await request(app).post("/admin/tech-steps/retrain").send({});
|
||||
expect(post.status).to.equal(401);
|
||||
});
|
||||
|
||||
describe("GET /suggestions", () => {
|
||||
it("groups suggestions by technique and filters by status", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
await seedCorrectionAndSuggestion({
|
||||
clause: "Faire mijoter",
|
||||
correctedKey: "simmer",
|
||||
withSuggestion: { status: "pending", synonyms: ["laisser frémir"] },
|
||||
});
|
||||
await seedCorrectionAndSuggestion({
|
||||
clause: "Émincer les oignons",
|
||||
correctedKey: "chop",
|
||||
withSuggestion: { status: "applied", synonyms: ["ciseler"] },
|
||||
});
|
||||
|
||||
const agent = await adminAgent();
|
||||
|
||||
const all = await agent.get("/admin/tech-steps/suggestions");
|
||||
expect(all.status).to.equal(200);
|
||||
expect(all.body.map((g: { techStepKey: string }) => g.techStepKey)).to.have.members([
|
||||
"chop",
|
||||
"simmer",
|
||||
]);
|
||||
const simmerGroup = all.body.find((g: { techStepKey: string }) => g.techStepKey === "simmer");
|
||||
expect(simmerGroup.suggestions[0].sourceCorrection.clauseText).to.equal("Faire mijoter");
|
||||
|
||||
const pendingOnly = await agent
|
||||
.get("/admin/tech-steps/suggestions")
|
||||
.query({ status: "pending" });
|
||||
expect(pendingOnly.body).to.have.length(1);
|
||||
expect(pendingOnly.body[0].techStepKey).to.equal("simmer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /suggestions/:id", () => {
|
||||
it("rejects an empty body with 400", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const { suggestion } = await seedCorrectionAndSuggestion({
|
||||
clause: "Faire mijoter",
|
||||
correctedKey: "simmer",
|
||||
withSuggestion: { status: "pending", synonyms: ["x"] },
|
||||
});
|
||||
const agent = await adminAgent();
|
||||
const res = await agent.patch(`/admin/tech-steps/suggestions/${suggestion?.id}`).send({});
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("404s an unknown id", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const agent = await adminAgent();
|
||||
const res = await agent
|
||||
.patch("/admin/tech-steps/suggestions/999999")
|
||||
.send({ status: "applied" });
|
||||
expect(res.status).to.equal(404);
|
||||
});
|
||||
|
||||
it("flips the status and edits the synonyms, reflected in a later GET", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const { suggestion } = await seedCorrectionAndSuggestion({
|
||||
clause: "Faire mijoter",
|
||||
correctedKey: "simmer",
|
||||
withSuggestion: { status: "pending", synonyms: ["frémir"] },
|
||||
});
|
||||
const agent = await adminAgent();
|
||||
|
||||
const patched = await agent
|
||||
.patch(`/admin/tech-steps/suggestions/${suggestion?.id}`)
|
||||
.send({ status: "applied", suggestedSynonyms: ["frémir", "mijoter doucement"] });
|
||||
expect(patched.status).to.equal(200);
|
||||
expect(patched.body.status).to.equal("applied");
|
||||
expect(patched.body.suggestedSynonyms).to.deep.equal(["frémir", "mijoter doucement"]);
|
||||
|
||||
const stored = await prisma.techStepTrainingSuggestion.findUniqueOrThrow({
|
||||
where: { id: suggestion?.id },
|
||||
});
|
||||
expect(stored.status).to.equal("applied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /corrections", () => {
|
||||
it("includes the 'no technique here' removals", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
await seedCorrectionAndSuggestion({ clause: "Rien ici", correctedKey: null });
|
||||
await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer" });
|
||||
|
||||
const agent = await adminAgent();
|
||||
const res = await agent.get("/admin/tech-steps/corrections");
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.have.length(2);
|
||||
|
||||
const removals = await agent
|
||||
.get("/admin/tech-steps/corrections")
|
||||
.query({ hasCorrectedTechStep: "false" });
|
||||
expect(removals.body).to.have.length(1);
|
||||
expect(removals.body[0].clauseText).to.equal("Rien ici");
|
||||
expect(removals.body[0].correctedTechStepKey).to.equal(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /training-data-snippet", () => {
|
||||
it("aggregates the applied suggestions' synonyms into a paste-ready block", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
await seedCorrectionAndSuggestion({
|
||||
clause: "Faire mijoter",
|
||||
correctedKey: "simmer",
|
||||
withSuggestion: { status: "applied", synonyms: ["frémir", "réduire à feu doux"] },
|
||||
});
|
||||
const agent = await adminAgent();
|
||||
const res = await agent
|
||||
.get("/admin/tech-steps/training-data-snippet")
|
||||
.query({ techStepKey: "simmer", locale: "fr", status: "applied" });
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.suggestionCount).to.equal(1);
|
||||
expect(res.body.synonyms).to.deep.equal(["frémir", "réduire à feu doux"]);
|
||||
expect(res.body.snippet).to.include('"frémir"');
|
||||
expect(res.body.snippet).to.include('"synonyms": [');
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /retrain", () => {
|
||||
it("runs the F1 gate and returns its result shape (needs tech-step-intent-service)", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
this.timeout(60000);
|
||||
const agent = await adminAgent();
|
||||
const res = await agent.post("/admin/tech-steps/retrain").send({});
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.have.keys([
|
||||
"f1",
|
||||
"precision",
|
||||
"recall",
|
||||
"minF1",
|
||||
"gatePassed",
|
||||
"backfilled",
|
||||
"marked",
|
||||
]);
|
||||
expect(res.body.minF1).to.equal(0.8);
|
||||
if (res.body.gatePassed) {
|
||||
expect(res.body.backfilled).to.have.keys(["total", "changed"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 409 RETRAIN_ALREADY_RUNNING while one is in flight", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
this.timeout(60000);
|
||||
const agent = await adminAgent();
|
||||
const first = agent.post("/admin/tech-steps/retrain").send({});
|
||||
// Let the first handler acquire the process-wide lock before the second starts.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const second = await agent.post("/admin/tech-steps/retrain").send({});
|
||||
expect(second.status).to.equal(409);
|
||||
expect(second.body.code).to.equal(ErrorCode.RETRAIN_ALREADY_RUNNING);
|
||||
await first;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -46,7 +46,8 @@
|
|||
"TECH_STEP_NOT_FOUND": "Cette technique n'existe pas",
|
||||
"UTENSIL_NOT_FOUND": "Un des ustensiles sélectionnés n'existe pas",
|
||||
"INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape",
|
||||
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
|
||||
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard",
|
||||
"RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours"
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ export enum ErrorCode {
|
|||
RECIPE_IN_USE = 4021,
|
||||
/** `POST /sources/:sourceKey/import/:externalId` attempted on an item already imported (a `Recipe` already exists for that `sourceId`/`externalId` pair). */
|
||||
RECIPE_ALREADY_IMPORTED = 4022,
|
||||
/** `POST /admin/tech-steps/retrain` attempted while a previous retrain (F1 gate + backfill) is still running — it holds a process-wide lock so two can't overlap. */
|
||||
RETRAIN_ALREADY_RUNNING = 4023,
|
||||
/** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
|
||||
NOT_HOUSE_ADMIN = 4030,
|
||||
/** `PATCH /recipes/:id` or `DELETE /recipes/:id` attempted by someone other than the recipe's author — visibility controls reading, not writing. */
|
||||
|
|
|
|||
|
|
@ -47,3 +47,60 @@ export const workerHeartbeatSchema = z.object({
|
|||
});
|
||||
/** 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>;
|
||||
|
|
|
|||
|
|
@ -116,3 +116,83 @@ export interface MonitoringView {
|
|||
generatedAt: string;
|
||||
services: ServiceHealthView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One `TechStepTrainingSuggestion` as shown in the admin triage UI, with
|
||||
* its source context resolved. `sourceCorrection` is present only when
|
||||
* `sourceType === "correction"` — for `"llm_audit"` the suggestion carries
|
||||
* no persisted clause context today (a known gap), just the synonyms/
|
||||
* utterances.
|
||||
*/
|
||||
export interface TrainingSuggestionAdminView {
|
||||
id: number;
|
||||
techStepKey: string;
|
||||
locale: string;
|
||||
suggestedSynonyms: string[];
|
||||
suggestedUtterances: string[];
|
||||
sourceType: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
sourceCorrection: {
|
||||
id: number;
|
||||
recipeId: number;
|
||||
stepId: number;
|
||||
clauseText: string;
|
||||
previousTechStepKey: string | null;
|
||||
correctedTechStepKey: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Suggestions grouped by their technique key — the shape `GET /admin/tech-steps/suggestions` returns. */
|
||||
export interface TrainingSuggestionGroupView {
|
||||
techStepKey: string;
|
||||
suggestions: TrainingSuggestionAdminView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One raw `StepTechStepCorrection` for the admin corrections browser —
|
||||
* includes the "no technique here" removals (`correctedTechStepKey: null`)
|
||||
* that never become suggestions and are invisible to every other tool.
|
||||
*/
|
||||
export interface CorrectionAdminView {
|
||||
id: number;
|
||||
recipeId: number;
|
||||
stepId: number;
|
||||
stepDescription: string;
|
||||
clauseText: string;
|
||||
start: number;
|
||||
end: number;
|
||||
previousTechStepKey: string | null;
|
||||
correctedTechStepKey: string | null;
|
||||
createdAt: string;
|
||||
consumedAt: string | null;
|
||||
}
|
||||
|
||||
/** Response of `GET /admin/tech-steps/training-data-snippet` — a ready-to-paste block. */
|
||||
export interface TrainingDataSnippetView {
|
||||
techStepKey: string;
|
||||
locale: string;
|
||||
status: string;
|
||||
/** Number of suggestions aggregated into the snippet. */
|
||||
suggestionCount: number;
|
||||
synonyms: string[];
|
||||
utterances: string[];
|
||||
/** The `synonyms`/`utterances` rendered as a Python literal block for `training_data.py`. */
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of `POST /admin/tech-steps/retrain`. `gatePassed` is the F1
|
||||
* regression gate (`f1 >= minF1`); `backfilled` is present only when the
|
||||
* gate passed (a failed gate refuses to backfill). `marked` echoes how
|
||||
* many suggestion ids were actually flipped.
|
||||
*/
|
||||
export interface RetrainResultView {
|
||||
f1: number;
|
||||
precision: number;
|
||||
recall: number;
|
||||
minF1: number;
|
||||
gatePassed: boolean;
|
||||
backfilled: { total: number; changed: number } | null;
|
||||
marked: { applied: number; rejected: number };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -404,6 +404,29 @@ scheduler, et après chaque job (avec `job`/`ok`/`counts`) — best-effort, un
|
|||
heartbeat en échec ne casse jamais un run. Seuils d'âge : > 8 j ⇒ `degraded`,
|
||||
> 21 j ⇒ `down` (cron par défaut hebdomadaire).
|
||||
|
||||
**Tri des corrections** (`admin-tech-steps.service.ts`, routes
|
||||
`/admin/tech-steps/*`, `requireAdmin`) — remplace le duo CLI
|
||||
`list-pending-training-suggestions.ts` / `retrain-tech-steps.ts` :
|
||||
|
||||
- `GET /suggestions` — `TechStepTrainingSuggestion` filtrées
|
||||
(`status`/`sourceType`/`techStepKey`/`locale`), **groupées par technique**,
|
||||
enrichies du contexte de la correction source (clause = `description.slice`).
|
||||
- `GET /corrections` — `StepTechStepCorrection` brutes, filtrables
|
||||
(`consumed`/`hasCorrectedTechStep`), **incluant** les suppressions
|
||||
`correctedTechStepId: null` invisibles ailleurs.
|
||||
- `PATCH /suggestions/:id` — édite `suggestedSynonyms`/`suggestedUtterances`
|
||||
et/ou `status` (`pending|applied|rejected`). Corps vide ⇒ `400`.
|
||||
- `GET /training-data-snippet?techStepKey=&locale=&status=` — agrège
|
||||
synonymes/phrases des suggestions retenues en un bloc `training_data.py`
|
||||
à coller (lecture seule).
|
||||
- `POST /retrain` — enveloppe `retrain-tech-steps.ts` **sans shell-out** :
|
||||
`runTechStepEvalSuite()` (gate F1 vs `MIN_OVERALL_F1`), puis si passé
|
||||
`backfillTechSteps()` + marquage des ids `applied`/`rejected`. Verrou
|
||||
mémoire process-wide ⇒ `409 RETRAIN_ALREADY_RUNNING` si un retrain tourne
|
||||
déjà. Gate échoué ⇒ `200` avec `gatePassed: false`, aucun backfill.
|
||||
**Ne peut ni éditer `training_data.py` ni redémarrer l'intent-service** —
|
||||
ces deux étapes restent manuelles, l'UI l'affiche en bandeau permanent.
|
||||
|
||||
---
|
||||
|
||||
## `reference` — catalogues publics (pas de session requise)
|
||||
|
|
|
|||
Loading…
Reference in a new issue