feat(admin): tri des corrections + declenchement du gate F1/backfill
Some checks failed
CI / test (push) Failing after 25s
CI / lint (push) Successful in 4m22s
CI / build (push) Successful in 4m13s
CI / e2e (push) Successful in 12m5s
CI / intent-service-test (push) Successful in 20m5s

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:
Nicolas 2026-08-28 13:06:55 +02:00
parent b61636f73d
commit 15dad91a43
15 changed files with 1801 additions and 7 deletions

View 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 → ∅",
);
});
});

View file

@ -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`. */

View file

@ -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"
}
}
}
}

View file

@ -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>
);
}

View 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;
}
}

View 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]);
}

View 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));
}),
);

View 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;
}
}

View file

@ -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);

View 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;
});
});
});

View file

@ -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": {

View file

@ -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. */

View file

@ -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>;

View file

@ -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 };
}

View file

@ -353,6 +353,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)