feat(admin): tri des corrections + declenchement du gate F1/backfill
Some checks failed
CI / build (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / intent-service-test (push) Has been cancelled
CI / test (push) Has been cancelled

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 57cb2b7b3d
commit ed68e1146f
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 AdminLoginInput,
type AdminUserView, type AdminUserView,
type ApiErrorResponse, type ApiErrorResponse,
type CorrectionAdminView,
ErrorCode, ErrorCode,
type MetricsView, type MetricsView,
type MonitoringView, type MonitoringView,
type RetrainRequestInput,
type RetrainResultView,
type TrainingDataSnippetView,
type TrainingSuggestionAdminView,
type TrainingSuggestionGroupView,
type UpdateTrainingSuggestionInput,
} from "@batch-cooking/shared"; } 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` * Base URL of the admin API surface, configurable via `VITE_ADMIN_API_URL`
* (see `.env.example`). Defaults to `""` (same origin) correct behind a * (see `.env.example`). Defaults to `""` (same origin) correct behind a
@ -102,6 +117,52 @@ export class AdminApiClient {
public getMonitoring(): Promise<MonitoringView> { public getMonitoring(): Promise<MonitoringView> {
return this._request("/admin/monitoring"); 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`. */ /** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */

View file

@ -5,6 +5,7 @@
"NOT_AUTHENTICATED": "Vous devez être connecté", "NOT_AUTHENTICATED": "Vous devez être connecté",
"NOT_FOUND": "Ressource introuvable", "NOT_FOUND": "Ressource introuvable",
"TECH_STEP_NOT_FOUND": "Cette technique n'existe pas", "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" "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
}, },
"admin": { "admin": {
@ -81,7 +82,47 @@
}, },
"corrections": { "corrections": {
"title": "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 { useTranslation } from "react-i18next";
import { ApiError, adminApiClient } from "../../api/client";
import { errorMessageService } from "../../services/error-message.service";
import "../admin-page.scss"; import "../admin-page.scss";
import "./corrections-page.scss";
import { linesToList, listsDiffer, listToLines } from "./corrections";
type Tab = "suggestions" | "corrections";
/** /**
* Tech-step correction triage review `TechStepTrainingSuggestion` / * Tech-step correction triage. Two tabs curated `TrainingSuggestion`s and
* `StepTechStepCorrection`, mark applied/rejected, generate the * raw `StepTechStepCorrection`s plus the snippet generator and the F1
* `training_data.py` snippet, and trigger the F1 gate + backfill. Placeholder * gate + backfill trigger. Replaces the `list-pending-training-suggestions.ts`
* until PR 5 (correction triage + retrain). * / `retrain-tech-steps.ts` CLI pair.
*/ */
export function CorrectionsPage() { export function CorrectionsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [tab, setTab] = useState<Tab>("suggestions");
return ( return (
<div className="admin-page"> <div className="admin-page">
<h1 className="admin-page__title">{t("admin.corrections.title")}</h1> <h1 className="admin-page__title">{t("admin.corrections.title")}</h1>
<p className="admin-page__lead">{t("admin.corrections.lead")}</p> <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> </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 { adminAuthRouter } from "./admin-auth.routes.js";
import { adminMetricsRouter } from "./admin-metrics.routes.js"; import { adminMetricsRouter } from "./admin-metrics.routes.js";
import { adminMonitoringRouter } from "./admin-monitoring.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` * 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("/auth", adminAuthRouter);
adminRouter.use("/metrics", adminMetricsRouter); adminRouter.use("/metrics", adminMetricsRouter);
adminRouter.use("/monitoring", adminMonitoringRouter); 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", "TECH_STEP_NOT_FOUND": "Cette technique n'existe pas",
"UTENSIL_NOT_FOUND": "Un des ustensiles sélectionnés 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", "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": { "auth": {
"login": { "login": {

View file

@ -40,6 +40,8 @@ export enum ErrorCode {
RECIPE_IN_USE = 4021, RECIPE_IN_USE = 4021,
/** `POST /sources/:sourceKey/import/:externalId` attempted on an item already imported (a `Recipe` already exists for that `sourceId`/`externalId` pair). */ /** `POST /sources/:sourceKey/import/:externalId` attempted on an item already imported (a `Recipe` already exists for that `sourceId`/`externalId` pair). */
RECIPE_ALREADY_IMPORTED = 4022, 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. */ /** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
NOT_HOUSE_ADMIN = 4030, 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. */ /** `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. */ /** Inferred TS type for {@link workerHeartbeatSchema}'s validated output. */
export type WorkerHeartbeatInput = z.infer<typeof workerHeartbeatSchema>; 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; generatedAt: string;
services: ServiceHealthView[]; 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`, heartbeat en échec ne casse jamais un run. Seuils d'âge : > 8 j ⇒ `degraded`,
> 21 j ⇒ `down` (cron par défaut hebdomadaire). > 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) ## `reference` — catalogues publics (pas de session requise)