feat(admin): tri des corrections + declenchement du gate F1/backfill
PR 5 (derniere) du chantier admin. Remplace le duo CLI list-pending-training-suggestions.ts / retrain-tech-steps.ts par une UI. API (admin-tech-steps.service.ts, routes /admin/tech-steps/*, requireAdmin) : - GET /suggestions : TechStepTrainingSuggestion filtrees, groupees par technique, enrichies du contexte de la correction source. - GET /corrections : corrections brutes filtrables, incluant les suppressions correctedTechStepId:null invisibles ailleurs. - PATCH /suggestions/:id : edite synonymes/phrases et/ou status. - GET /training-data-snippet : bloc training_data.py a coller (lecture seule). - POST /retrain : runTechStepEvalSuite() (gate F1 vs MIN_OVERALL_F1) puis si passe backfillTechSteps() + marquage applied/rejected. Verrou memoire -> 409 RETRAIN_ALREADY_RUNNING. Gate echoue -> 200 gatePassed:false. N'edite pas le .py ni ne redemarre l'intent-service (manuel). Shared : nouveau ErrorCode RETRAIN_ALREADY_RUNNING (4023, + cle i18n apps/web), schemas (list*/update*/retrain*/snippet), types (TrainingSuggestion*/Correction*/RetrainResultView...). Front : CorrectionsPage (onglets Suggestions / Corrections brutes, bandeau caveat permanent, cartes editables + Appliquer/Rejeter, panneau snippet, panneau gate F1). Logique pure corrections.ts. i18n admin.corrections.*. AdminApiClient : 5 methodes. Tests : Mocha admin-tech-steps.test.ts (401 partout, groupement+filtre, PATCH 400/404/ok, corrections incluant removals, snippet, retrain shape + 409 concurrent) ; Cypress corrections.cy.ts (4 verts). Admin-web Cypress 13/13. specs/backend-architecture.md : section tri + retrain. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
57cb2b7b3d
commit
ed68e1146f
15 changed files with 1801 additions and 7 deletions
159
apps/admin-web/cypress/e2e/corrections.cy.ts
Normal file
159
apps/admin-web/cypress/e2e/corrections.cy.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
// Mocks the admin API via cy.intercept — no live backend.
|
||||||
|
|
||||||
|
const adminBody = {
|
||||||
|
id: 1,
|
||||||
|
email: "ops@example.com",
|
||||||
|
name: "Ops",
|
||||||
|
createdAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
lastLoginAt: "2026-08-28T09:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
function suggestionGroups() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
techStepKey: "simmer",
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
techStepKey: "simmer",
|
||||||
|
locale: "fr",
|
||||||
|
suggestedSynonyms: ["frémir"],
|
||||||
|
suggestedUtterances: ["laisser cuire tout doucement"],
|
||||||
|
sourceType: "correction",
|
||||||
|
status: "pending",
|
||||||
|
createdAt: "2026-08-20T00:00:00.000Z",
|
||||||
|
sourceCorrection: {
|
||||||
|
id: 5,
|
||||||
|
recipeId: 2,
|
||||||
|
stepId: 7,
|
||||||
|
clauseText: "faire mijoter la sauce",
|
||||||
|
previousTechStepKey: "cook",
|
||||||
|
correctedTechStepKey: "simmer",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function corrections() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
recipeId: 2,
|
||||||
|
stepId: 7,
|
||||||
|
stepDescription: "Faire mijoter la sauce 20 min.",
|
||||||
|
clauseText: "faire mijoter la sauce",
|
||||||
|
start: 0,
|
||||||
|
end: 21,
|
||||||
|
previousTechStepKey: "cook",
|
||||||
|
correctedTechStepKey: "simmer",
|
||||||
|
createdAt: "2026-08-20T00:00:00.000Z",
|
||||||
|
consumedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
recipeId: 3,
|
||||||
|
stepId: 9,
|
||||||
|
stepDescription: "Réserver au frais.",
|
||||||
|
clauseText: "Réserver au frais",
|
||||||
|
start: 0,
|
||||||
|
end: 17,
|
||||||
|
previousTechStepKey: "setAside",
|
||||||
|
correctedTechStepKey: null,
|
||||||
|
createdAt: "2026-08-19T00:00:00.000Z",
|
||||||
|
consumedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Admin corrections triage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.viewport(1400, 1000);
|
||||||
|
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
|
||||||
|
cy.intercept("GET", "**/admin/tech-steps/suggestions*", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: suggestionGroups(),
|
||||||
|
}).as("getSuggestions");
|
||||||
|
cy.intercept("GET", "**/admin/tech-steps/corrections*", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: corrections(),
|
||||||
|
}).as("getCorrections");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the caveat, groups suggestions by technique, and applies one", () => {
|
||||||
|
cy.intercept("PATCH", "**/admin/tech-steps/suggestions/11", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: { ...suggestionGroups()[0].suggestions[0], status: "applied" },
|
||||||
|
}).as("patch");
|
||||||
|
|
||||||
|
cy.visit("/corrections");
|
||||||
|
cy.wait("@getSuggestions");
|
||||||
|
|
||||||
|
cy.contains(".corrections-caveat", "training_data.py").should("be.visible");
|
||||||
|
cy.contains(".suggestion-group h2", "simmer").should("be.visible");
|
||||||
|
cy.contains(".suggestion-card", "faire mijoter la sauce").should(
|
||||||
|
"contain.text",
|
||||||
|
"cook → simmer",
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.contains(".suggestion-card button", "Appliquer").click();
|
||||||
|
cy.wait("@patch").its("request.body").should("deep.equal", { status: "applied" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates a training_data.py snippet", () => {
|
||||||
|
cy.intercept("GET", "**/admin/tech-steps/training-data-snippet*", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
techStepKey: "simmer",
|
||||||
|
locale: "fr",
|
||||||
|
status: "applied",
|
||||||
|
suggestionCount: 2,
|
||||||
|
synonyms: ["frémir", "réduire"],
|
||||||
|
utterances: [],
|
||||||
|
snippet:
|
||||||
|
'# simmer (fr) — 2 suggestion(s) "applied"\n"synonyms": [\n "frémir",\n "réduire",\n],',
|
||||||
|
},
|
||||||
|
}).as("getSnippet");
|
||||||
|
|
||||||
|
cy.visit("/corrections");
|
||||||
|
cy.get(".corrections-panel input").type("simmer");
|
||||||
|
cy.contains(".corrections-panel button", "Générer").click();
|
||||||
|
cy.wait("@getSnippet");
|
||||||
|
cy.get(".corrections-snippet").should("contain.value", '"synonyms": [');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the F1 gate and shows the result", () => {
|
||||||
|
cy.intercept("POST", "**/admin/tech-steps/retrain", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
f1: 0.83,
|
||||||
|
precision: 0.8,
|
||||||
|
recall: 0.86,
|
||||||
|
minF1: 0.8,
|
||||||
|
gatePassed: true,
|
||||||
|
backfilled: { total: 120, changed: 4 },
|
||||||
|
marked: { applied: 0, rejected: 0 },
|
||||||
|
},
|
||||||
|
}).as("retrain");
|
||||||
|
|
||||||
|
cy.visit("/corrections");
|
||||||
|
cy.contains(".corrections-panel--retrain button", "Lancer").click();
|
||||||
|
cy.wait("@retrain");
|
||||||
|
cy.contains(".retrain-result", "F1 0.830")
|
||||||
|
.should("have.class", "retrain-result--ok")
|
||||||
|
.and("contain.text", "4/120");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists raw corrections including the removals, on the second tab", () => {
|
||||||
|
cy.visit("/corrections");
|
||||||
|
cy.contains(".corrections-tabs button", "Corrections brutes").click();
|
||||||
|
cy.wait("@getCorrections");
|
||||||
|
|
||||||
|
cy.get(".corrections-table tbody tr").should("have.length", 2);
|
||||||
|
cy.contains(".corrections-table tr", "Réserver au frais").should(
|
||||||
|
"contain.text",
|
||||||
|
"setAside → ∅",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,11 +2,26 @@ import {
|
||||||
type AdminLoginInput,
|
type 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`. */
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
266
apps/admin-web/src/pages/corrections/corrections-page.scss
Normal file
266
apps/admin-web/src/pages/corrections/corrections-page.scss
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
// =============================================================================
|
||||||
|
// CorrectionsPage — a caveat banner, two tabs, filter rows, suggestion cards
|
||||||
|
// with editable textareas, the snippet + retrain panels, and the raw
|
||||||
|
// corrections table.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.corrections-caveat {
|
||||||
|
margin: 0 0 var(--space-lg);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-left: 4px solid var(--color-warning);
|
||||||
|
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface));
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.corrections-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.corrections-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.corrections-panel {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
padding: var(--space-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 var(--space-sm);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--retrain {
|
||||||
|
border-left: 4px solid var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: var(--space-xs) var(--space-md);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-surface);
|
||||||
|
background: var(--color-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.corrections-snippet {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
padding: var(--space-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
color: var(--color-text);
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retrain-result {
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
&--ok {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
&--fail {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-group {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-card {
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
padding: var(--space-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-left: 4px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
|
||||||
|
&--applied {
|
||||||
|
border-left-color: var(--color-success);
|
||||||
|
}
|
||||||
|
&--rejected {
|
||||||
|
border-left-color: var(--color-error);
|
||||||
|
}
|
||||||
|
&--pending {
|
||||||
|
border-left-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__source {
|
||||||
|
margin: var(--space-xs) 0 var(--space-sm);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__clause {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__error {
|
||||||
|
margin: 0 0 var(--space-sm);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: var(--space-xs) var(--space-md);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.corrections-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corrections-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__clause {
|
||||||
|
font-style: italic;
|
||||||
|
max-width: 28rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/admin-web/src/pages/corrections/corrections.ts
Normal file
23
apps/admin-web/src/pages/corrections/corrections.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
/**
|
||||||
|
* Pure helpers for `CorrectionsPage` — the editable synonym/utterance
|
||||||
|
* fields are one-per-line textareas, so these convert between that and the
|
||||||
|
* `string[]` the API wants. Kept out of the `.tsx` per repo convention.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Textarea value (one entry per line) → trimmed, non-empty `string[]`. */
|
||||||
|
export function linesToList(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `string[]` → textarea value (one entry per line). */
|
||||||
|
export function listToLines(values: string[]): string {
|
||||||
|
return values.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when two string lists differ (order-sensitive) — gates the "save" button. */
|
||||||
|
export function listsDiffer(a: string[], b: string[]): boolean {
|
||||||
|
return a.length !== b.length || a.some((value, i) => value !== b[i]);
|
||||||
|
}
|
||||||
80
apps/api/src/modules/admin/admin-tech-steps.routes.ts
Normal file
80
apps/api/src/modules/admin/admin-tech-steps.routes.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
|
import {
|
||||||
|
ErrorCode,
|
||||||
|
listCorrectionsQuerySchema,
|
||||||
|
listSuggestionsQuerySchema,
|
||||||
|
retrainRequestSchema,
|
||||||
|
trainingDataSnippetQuerySchema,
|
||||||
|
updateTrainingSuggestionSchema,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
import { Router } from "express";
|
||||||
|
import { requireAdmin } from "../../middlewares/require-admin.js";
|
||||||
|
import {
|
||||||
|
getTrainingDataSnippet,
|
||||||
|
listCorrections,
|
||||||
|
listSuggestions,
|
||||||
|
runRetrain,
|
||||||
|
updateSuggestion,
|
||||||
|
} from "./admin-tech-steps.service.js";
|
||||||
|
|
||||||
|
/** Router mounted at `/admin/tech-steps` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. Correction/suggestion triage + the retrain trigger. */
|
||||||
|
export const adminTechStepsRouter = Router();
|
||||||
|
|
||||||
|
adminTechStepsRouter.get(
|
||||||
|
"/suggestions",
|
||||||
|
requireAdmin,
|
||||||
|
wrapAsyncHandler(async (req, res) => {
|
||||||
|
res.status(200).json(await listSuggestions(listSuggestionsQuerySchema.parse(req.query)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
adminTechStepsRouter.get(
|
||||||
|
"/corrections",
|
||||||
|
requireAdmin,
|
||||||
|
wrapAsyncHandler(async (req, res) => {
|
||||||
|
res.status(200).json(await listCorrections(listCorrectionsQuerySchema.parse(req.query)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
adminTechStepsRouter.get(
|
||||||
|
"/training-data-snippet",
|
||||||
|
requireAdmin,
|
||||||
|
wrapAsyncHandler(async (req, res) => {
|
||||||
|
res
|
||||||
|
.status(200)
|
||||||
|
.json(await getTrainingDataSnippet(trainingDataSnippetQuerySchema.parse(req.query)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
adminTechStepsRouter.patch(
|
||||||
|
"/suggestions/:id",
|
||||||
|
requireAdmin,
|
||||||
|
wrapAsyncHandler(async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
|
throw new HttpError(
|
||||||
|
400,
|
||||||
|
ErrorCode.VALIDATION_ERROR,
|
||||||
|
`Not a valid suggestion id: ${req.params.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const input = updateTrainingSuggestionSchema.parse(req.body);
|
||||||
|
res.status(200).json(await updateSuggestion(id, input));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the F1 regression gate then (if it passes) the full step backfill,
|
||||||
|
* and marks the given suggestion ids — see {@link runRetrain}. Long-running
|
||||||
|
* and process-locked: `409 RETRAIN_ALREADY_RUNNING` if one is already
|
||||||
|
* underway.
|
||||||
|
*/
|
||||||
|
adminTechStepsRouter.post(
|
||||||
|
"/retrain",
|
||||||
|
requireAdmin,
|
||||||
|
wrapAsyncHandler(async (req, res) => {
|
||||||
|
const input = retrainRequestSchema.parse(req.body);
|
||||||
|
res.status(200).json(await runRetrain(input));
|
||||||
|
}),
|
||||||
|
);
|
||||||
325
apps/api/src/modules/admin/admin-tech-steps.service.ts
Normal file
325
apps/api/src/modules/admin/admin-tech-steps.service.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
|
import {
|
||||||
|
type CorrectionAdminView,
|
||||||
|
ErrorCode,
|
||||||
|
type ListCorrectionsQuery,
|
||||||
|
type ListSuggestionsQuery,
|
||||||
|
type RetrainRequestInput,
|
||||||
|
type RetrainResultView,
|
||||||
|
type TrainingDataSnippetQuery,
|
||||||
|
type TrainingDataSnippetView,
|
||||||
|
type TrainingSuggestionAdminView,
|
||||||
|
type TrainingSuggestionGroupView,
|
||||||
|
type UpdateTrainingSuggestionInput,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
import { prisma } from "../../db/prisma.js";
|
||||||
|
import {
|
||||||
|
MIN_OVERALL_F1,
|
||||||
|
runTechStepEvalSuite,
|
||||||
|
} from "../../lib/recipe-matching/tech-step-eval-runner.js";
|
||||||
|
import { backfillTechSteps } from "../../scripts/backfill-tech-steps.js";
|
||||||
|
|
||||||
|
/** How many raw corrections `listCorrections` returns per call — the browser is a triage view, not an export. */
|
||||||
|
const CORRECTIONS_PAGE_SIZE = 200;
|
||||||
|
|
||||||
|
const suggestionInclude = {
|
||||||
|
techStep: { select: { key: true } },
|
||||||
|
sourceCorrection: {
|
||||||
|
include: {
|
||||||
|
step: { select: { id: true, recipeId: true, description: true } },
|
||||||
|
previousTechStep: { select: { key: true } },
|
||||||
|
correctedTechStep: { select: { key: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type SuggestionRow = Awaited<
|
||||||
|
ReturnType<
|
||||||
|
typeof prisma.techStepTrainingSuggestion.findFirstOrThrow<{ include: typeof suggestionInclude }>
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** Shapes one Prisma suggestion row (with {@link suggestionInclude}) into its admin view. */
|
||||||
|
function toSuggestionView(row: SuggestionRow): TrainingSuggestionAdminView {
|
||||||
|
const correction = row.sourceCorrection;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
techStepKey: row.techStep.key,
|
||||||
|
locale: row.locale,
|
||||||
|
suggestedSynonyms: row.suggestedSynonyms,
|
||||||
|
suggestedUtterances: row.suggestedUtterances,
|
||||||
|
sourceType: row.sourceType,
|
||||||
|
status: row.status,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
sourceCorrection: correction
|
||||||
|
? {
|
||||||
|
id: correction.id,
|
||||||
|
recipeId: correction.step.recipeId,
|
||||||
|
stepId: correction.step.id,
|
||||||
|
clauseText: correction.step.description.slice(correction.start, correction.end),
|
||||||
|
previousTechStepKey: correction.previousTechStep?.key ?? null,
|
||||||
|
correctedTechStepKey: correction.correctedTechStep?.key ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every `TechStepTrainingSuggestion` matching the (all-optional) filters,
|
||||||
|
* grouped by technique key — same "one block per technique" organisation
|
||||||
|
* as `list-pending-training-suggestions.ts`'s CLI report, which this UI
|
||||||
|
* replaces.
|
||||||
|
*/
|
||||||
|
export async function listSuggestions(
|
||||||
|
query: ListSuggestionsQuery,
|
||||||
|
): Promise<TrainingSuggestionGroupView[]> {
|
||||||
|
try {
|
||||||
|
const rows = await prisma.techStepTrainingSuggestion.findMany({
|
||||||
|
where: {
|
||||||
|
...(query.status ? { status: query.status } : {}),
|
||||||
|
...(query.sourceType ? { sourceType: query.sourceType } : {}),
|
||||||
|
...(query.locale ? { locale: query.locale } : {}),
|
||||||
|
...(query.techStepKey ? { techStep: { key: query.techStepKey } } : {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ techStepId: "asc" }, { createdAt: "asc" }],
|
||||||
|
include: suggestionInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
const byKey = new Map<string, TrainingSuggestionAdminView[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const view = toSuggestionView(row);
|
||||||
|
const group = byKey.get(view.techStepKey);
|
||||||
|
if (group) group.push(view);
|
||||||
|
else byKey.set(view.techStepKey, [view]);
|
||||||
|
}
|
||||||
|
return [...byKey.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([techStepKey, suggestions]) => ({ techStepKey, suggestions }));
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see recipe.service.ts's equivalent catch comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw `StepTechStepCorrection`s for the admin browser — newest first,
|
||||||
|
* capped at {@link CORRECTIONS_PAGE_SIZE}. Unlike the worker's own
|
||||||
|
* `getPendingCorrections`, this **includes** the `correctedTechStepId IS NULL`
|
||||||
|
* removals ("no technique here") that never become suggestions and are
|
||||||
|
* otherwise invisible.
|
||||||
|
*/
|
||||||
|
export async function listCorrections(query: ListCorrectionsQuery): Promise<CorrectionAdminView[]> {
|
||||||
|
try {
|
||||||
|
const consumedFilter =
|
||||||
|
query.consumed === "true"
|
||||||
|
? { consumedAt: { not: null } }
|
||||||
|
: query.consumed === "false"
|
||||||
|
? { consumedAt: null }
|
||||||
|
: {};
|
||||||
|
const correctedFilter =
|
||||||
|
query.hasCorrectedTechStep === "true"
|
||||||
|
? { correctedTechStepId: { not: null } }
|
||||||
|
: query.hasCorrectedTechStep === "false"
|
||||||
|
? { correctedTechStepId: null }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const rows = await prisma.stepTechStepCorrection.findMany({
|
||||||
|
where: { ...consumedFilter, ...correctedFilter },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: CORRECTIONS_PAGE_SIZE,
|
||||||
|
include: {
|
||||||
|
step: { select: { id: true, recipeId: true, description: true } },
|
||||||
|
previousTechStep: { select: { key: true } },
|
||||||
|
correctedTechStep: { select: { key: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
recipeId: row.step.recipeId,
|
||||||
|
stepId: row.step.id,
|
||||||
|
stepDescription: row.step.description,
|
||||||
|
clauseText: row.step.description.slice(row.start, row.end),
|
||||||
|
start: row.start,
|
||||||
|
end: row.end,
|
||||||
|
previousTechStepKey: row.previousTechStep?.key ?? null,
|
||||||
|
correctedTechStepKey: row.correctedTechStep?.key ?? null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
consumedAt: row.consumedAt?.toISOString() ?? null,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curates one suggestion — edit its proposed synonyms/utterances and/or
|
||||||
|
* flip its `status`. At least one field must be present.
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `400 VALIDATION_ERROR` if the body is empty.
|
||||||
|
* @throws {HttpError} `404 NOT_FOUND` if `id` matches no suggestion.
|
||||||
|
*/
|
||||||
|
export async function updateSuggestion(
|
||||||
|
id: number,
|
||||||
|
input: UpdateTrainingSuggestionInput,
|
||||||
|
): Promise<TrainingSuggestionAdminView> {
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
input.status === undefined &&
|
||||||
|
input.suggestedSynonyms === undefined &&
|
||||||
|
input.suggestedUtterances === undefined
|
||||||
|
) {
|
||||||
|
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Nothing to update");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.techStepTrainingSuggestion.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new HttpError(404, ErrorCode.NOT_FOUND, `Training suggestion ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.techStepTrainingSuggestion.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(input.status !== undefined ? { status: input.status } : {}),
|
||||||
|
...(input.suggestedSynonyms !== undefined
|
||||||
|
? { suggestedSynonyms: input.suggestedSynonyms }
|
||||||
|
: {}),
|
||||||
|
...(input.suggestedUtterances !== undefined
|
||||||
|
? { suggestedUtterances: input.suggestedUtterances }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
include: suggestionInclude,
|
||||||
|
});
|
||||||
|
return toSuggestionView(updated);
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deduplicates while preserving first-seen order — for pooling synonyms/utterances across suggestions. */
|
||||||
|
function dedupe(values: string[]): string[] {
|
||||||
|
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Indents each entry as a Python list literal line (4-space, trailing comma) — the shape `training_data.py`'s blocks use. */
|
||||||
|
function pythonListBody(entries: string[]): string {
|
||||||
|
return entries.map((entry) => ` ${JSON.stringify(entry)},`).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregates the synonyms/utterances of every suggestion matching
|
||||||
|
* `techStepKey` + `locale` + `status` into a ready-to-paste
|
||||||
|
* `training_data.py` block. **Read-only** — editing that Python file and
|
||||||
|
* restarting the intent-service stay a manual maintainer step.
|
||||||
|
*/
|
||||||
|
export async function getTrainingDataSnippet(
|
||||||
|
query: TrainingDataSnippetQuery,
|
||||||
|
): Promise<TrainingDataSnippetView> {
|
||||||
|
try {
|
||||||
|
const rows = await prisma.techStepTrainingSuggestion.findMany({
|
||||||
|
where: {
|
||||||
|
locale: query.locale,
|
||||||
|
status: query.status,
|
||||||
|
techStep: { key: query.techStepKey },
|
||||||
|
},
|
||||||
|
select: { suggestedSynonyms: true, suggestedUtterances: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const synonyms = dedupe(rows.flatMap((row) => row.suggestedSynonyms));
|
||||||
|
const utterances = dedupe(rows.flatMap((row) => row.suggestedUtterances));
|
||||||
|
|
||||||
|
const snippet = [
|
||||||
|
`# ${query.techStepKey} (${query.locale}) — ${rows.length} suggestion(s) "${query.status}"`,
|
||||||
|
`"synonyms": [`,
|
||||||
|
pythonListBody(synonyms),
|
||||||
|
`],`,
|
||||||
|
`"utterances": [`,
|
||||||
|
pythonListBody(utterances),
|
||||||
|
`],`,
|
||||||
|
]
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return {
|
||||||
|
techStepKey: query.techStepKey,
|
||||||
|
locale: query.locale,
|
||||||
|
status: query.status,
|
||||||
|
suggestionCount: rows.length,
|
||||||
|
synonyms,
|
||||||
|
utterances,
|
||||||
|
snippet,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Process-wide lock — the F1 gate + full backfill is heavy and must never run twice concurrently. */
|
||||||
|
let retrainInProgress = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the training-corpus regression gate then, if it passes, backfills
|
||||||
|
* every step and marks the given suggestion ids — the same three steps as
|
||||||
|
* `scripts/retrain-tech-steps.ts`, callable from the admin UI.
|
||||||
|
*
|
||||||
|
* **Only meaningful after** a maintainer has hand-edited
|
||||||
|
* `services/tech-step-intent-service/intent_service/training_data.py` **and
|
||||||
|
* restarted that service** (it trains once at boot) — this endpoint can do
|
||||||
|
* neither, and the admin UI states that prominently.
|
||||||
|
*
|
||||||
|
* A failed gate returns `gatePassed: false` with no backfill / no marking
|
||||||
|
* (HTTP 200 — it's an expected outcome to show the operator, not an error).
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `409 RETRAIN_ALREADY_RUNNING` if a retrain is already in flight.
|
||||||
|
*/
|
||||||
|
export async function runRetrain(input: RetrainRequestInput): Promise<RetrainResultView> {
|
||||||
|
if (retrainInProgress) {
|
||||||
|
throw new HttpError(
|
||||||
|
409,
|
||||||
|
ErrorCode.RETRAIN_ALREADY_RUNNING,
|
||||||
|
"A retrain (F1 gate + backfill) is already running",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
retrainInProgress = true;
|
||||||
|
try {
|
||||||
|
const { overall } = await runTechStepEvalSuite();
|
||||||
|
const gatePassed = overall.f1 >= MIN_OVERALL_F1;
|
||||||
|
|
||||||
|
let backfilled: RetrainResultView["backfilled"] = null;
|
||||||
|
const marked = { applied: 0, rejected: 0 };
|
||||||
|
|
||||||
|
if (gatePassed) {
|
||||||
|
backfilled = await backfillTechSteps();
|
||||||
|
|
||||||
|
const appliedIds = input.appliedIds ?? [];
|
||||||
|
const rejectedIds = input.rejectedIds ?? [];
|
||||||
|
if (appliedIds.length > 0) {
|
||||||
|
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
|
||||||
|
where: { id: { in: appliedIds } },
|
||||||
|
data: { status: "applied" },
|
||||||
|
});
|
||||||
|
marked.applied = count;
|
||||||
|
}
|
||||||
|
if (rejectedIds.length > 0) {
|
||||||
|
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
|
||||||
|
where: { id: { in: rejectedIds } },
|
||||||
|
data: { status: "rejected" },
|
||||||
|
});
|
||||||
|
marked.rejected = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
f1: overall.f1,
|
||||||
|
precision: overall.precision,
|
||||||
|
recall: overall.recall,
|
||||||
|
minF1: MIN_OVERALL_F1,
|
||||||
|
gatePassed,
|
||||||
|
backfilled,
|
||||||
|
marked,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see recipe.service.ts's equivalent catch comment
|
||||||
|
} finally {
|
||||||
|
retrainInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { Router } from "express";
|
||||||
import { adminAuthRouter } from "./admin-auth.routes.js";
|
import { 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);
|
||||||
|
|
|
||||||
298
apps/api/test/admin-tech-steps.test.ts
Normal file
298
apps/api/test/admin-tech-steps.test.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
|
import { faker } from "@faker-js/faker";
|
||||||
|
import { expect } from "chai";
|
||||||
|
import request from "supertest";
|
||||||
|
import { createApp } from "../src/app.js";
|
||||||
|
import { env } from "../src/config/env.js";
|
||||||
|
import { prisma } from "../src/db/prisma.js";
|
||||||
|
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
|
||||||
|
import { resetDatabase } from "../test-support/reset-db.js";
|
||||||
|
|
||||||
|
async function seedAdmin(): Promise<{ email: string; password: string }> {
|
||||||
|
const email = faker.internet.email().toLowerCase();
|
||||||
|
const password = faker.internet.password({ length: 16 });
|
||||||
|
await prisma.adminUser.create({
|
||||||
|
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
|
||||||
|
});
|
||||||
|
return { email, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function techStepId(key: string): Promise<number> {
|
||||||
|
return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A recipe + one step + one correction on it, optionally already turned into a suggestion. */
|
||||||
|
async function seedCorrectionAndSuggestion(options: {
|
||||||
|
clause: string;
|
||||||
|
correctedKey: string | null;
|
||||||
|
withSuggestion?: { status: string; synonyms: string[] };
|
||||||
|
}) {
|
||||||
|
const author = await prisma.userProfile.create({
|
||||||
|
data: {
|
||||||
|
firstName: "T",
|
||||||
|
lastName: "A",
|
||||||
|
email: `${faker.string.uuid()}@example.test`,
|
||||||
|
passwordHash: "x",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const recipe = await prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name: "R",
|
||||||
|
authorId: author.id,
|
||||||
|
portions: 4,
|
||||||
|
steps: { create: [{ description: options.clause, order: 0 }] },
|
||||||
|
},
|
||||||
|
include: { steps: true },
|
||||||
|
});
|
||||||
|
const step = recipe.steps[0];
|
||||||
|
if (!step) throw new Error("expected a step");
|
||||||
|
|
||||||
|
const correctedKey = options.correctedKey;
|
||||||
|
const correction = await prisma.stepTechStepCorrection.create({
|
||||||
|
data: {
|
||||||
|
stepId: step.id,
|
||||||
|
correctorId: author.id,
|
||||||
|
start: 0,
|
||||||
|
end: options.clause.length,
|
||||||
|
previousTechStepId: null,
|
||||||
|
correctedTechStepId: correctedKey === null ? null : await techStepId(correctedKey),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let suggestion: { id: number } | null = null;
|
||||||
|
if (options.withSuggestion && correctedKey !== null) {
|
||||||
|
suggestion = await prisma.techStepTrainingSuggestion.create({
|
||||||
|
data: {
|
||||||
|
techStepId: await techStepId(correctedKey),
|
||||||
|
locale: "fr",
|
||||||
|
suggestedSynonyms: options.withSuggestion.synonyms,
|
||||||
|
suggestedUtterances: [],
|
||||||
|
sourceType: "correction",
|
||||||
|
sourceCorrectionId: correction.id,
|
||||||
|
status: options.withSuggestion.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { recipeId: recipe.id, stepId: step.id, correctionId: correction.id, suggestion };
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
|
||||||
|
|
||||||
|
describe("Admin tech-steps triage", () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function adminAgent() {
|
||||||
|
const { email, password } = await seedAdmin();
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post("/admin/auth/login").send({ email, password });
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("rejects every route without an admin session", async () => {
|
||||||
|
for (const path of [
|
||||||
|
"/admin/tech-steps/suggestions",
|
||||||
|
"/admin/tech-steps/corrections",
|
||||||
|
"/admin/tech-steps/training-data-snippet?techStepKey=simmer",
|
||||||
|
]) {
|
||||||
|
const res = await request(app).get(path);
|
||||||
|
expect(res.status, path).to.equal(401);
|
||||||
|
}
|
||||||
|
const post = await request(app).post("/admin/tech-steps/retrain").send({});
|
||||||
|
expect(post.status).to.equal(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /suggestions", () => {
|
||||||
|
it("groups suggestions by technique and filters by status", async function () {
|
||||||
|
if (!adminSecretConfigured) {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
||||||
|
(this as any).skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await seedCorrectionAndSuggestion({
|
||||||
|
clause: "Faire mijoter",
|
||||||
|
correctedKey: "simmer",
|
||||||
|
withSuggestion: { status: "pending", synonyms: ["laisser frémir"] },
|
||||||
|
});
|
||||||
|
await seedCorrectionAndSuggestion({
|
||||||
|
clause: "Émincer les oignons",
|
||||||
|
correctedKey: "chop",
|
||||||
|
withSuggestion: { status: "applied", synonyms: ["ciseler"] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const agent = await adminAgent();
|
||||||
|
|
||||||
|
const all = await agent.get("/admin/tech-steps/suggestions");
|
||||||
|
expect(all.status).to.equal(200);
|
||||||
|
expect(all.body.map((g: { techStepKey: string }) => g.techStepKey)).to.have.members([
|
||||||
|
"chop",
|
||||||
|
"simmer",
|
||||||
|
]);
|
||||||
|
const simmerGroup = all.body.find((g: { techStepKey: string }) => g.techStepKey === "simmer");
|
||||||
|
expect(simmerGroup.suggestions[0].sourceCorrection.clauseText).to.equal("Faire mijoter");
|
||||||
|
|
||||||
|
const pendingOnly = await agent
|
||||||
|
.get("/admin/tech-steps/suggestions")
|
||||||
|
.query({ status: "pending" });
|
||||||
|
expect(pendingOnly.body).to.have.length(1);
|
||||||
|
expect(pendingOnly.body[0].techStepKey).to.equal("simmer");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PATCH /suggestions/:id", () => {
|
||||||
|
it("rejects an empty body with 400", async function () {
|
||||||
|
if (!adminSecretConfigured) {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||||
|
(this as any).skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { suggestion } = await seedCorrectionAndSuggestion({
|
||||||
|
clause: "Faire mijoter",
|
||||||
|
correctedKey: "simmer",
|
||||||
|
withSuggestion: { status: "pending", synonyms: ["x"] },
|
||||||
|
});
|
||||||
|
const agent = await adminAgent();
|
||||||
|
const res = await agent.patch(`/admin/tech-steps/suggestions/${suggestion?.id}`).send({});
|
||||||
|
expect(res.status).to.equal(400);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404s an unknown id", async function () {
|
||||||
|
if (!adminSecretConfigured) {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||||
|
(this as any).skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const agent = await adminAgent();
|
||||||
|
const res = await agent
|
||||||
|
.patch("/admin/tech-steps/suggestions/999999")
|
||||||
|
.send({ status: "applied" });
|
||||||
|
expect(res.status).to.equal(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips the status and edits the synonyms, reflected in a later GET", async function () {
|
||||||
|
if (!adminSecretConfigured) {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||||
|
(this as any).skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { suggestion } = await seedCorrectionAndSuggestion({
|
||||||
|
clause: "Faire mijoter",
|
||||||
|
correctedKey: "simmer",
|
||||||
|
withSuggestion: { status: "pending", synonyms: ["frémir"] },
|
||||||
|
});
|
||||||
|
const agent = await adminAgent();
|
||||||
|
|
||||||
|
const patched = await agent
|
||||||
|
.patch(`/admin/tech-steps/suggestions/${suggestion?.id}`)
|
||||||
|
.send({ status: "applied", suggestedSynonyms: ["frémir", "mijoter doucement"] });
|
||||||
|
expect(patched.status).to.equal(200);
|
||||||
|
expect(patched.body.status).to.equal("applied");
|
||||||
|
expect(patched.body.suggestedSynonyms).to.deep.equal(["frémir", "mijoter doucement"]);
|
||||||
|
|
||||||
|
const stored = await prisma.techStepTrainingSuggestion.findUniqueOrThrow({
|
||||||
|
where: { id: suggestion?.id },
|
||||||
|
});
|
||||||
|
expect(stored.status).to.equal("applied");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /corrections", () => {
|
||||||
|
it("includes the 'no technique here' removals", async function () {
|
||||||
|
if (!adminSecretConfigured) {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||||
|
(this as any).skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await seedCorrectionAndSuggestion({ clause: "Rien ici", correctedKey: null });
|
||||||
|
await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer" });
|
||||||
|
|
||||||
|
const agent = await adminAgent();
|
||||||
|
const res = await agent.get("/admin/tech-steps/corrections");
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.have.length(2);
|
||||||
|
|
||||||
|
const removals = await agent
|
||||||
|
.get("/admin/tech-steps/corrections")
|
||||||
|
.query({ hasCorrectedTechStep: "false" });
|
||||||
|
expect(removals.body).to.have.length(1);
|
||||||
|
expect(removals.body[0].clauseText).to.equal("Rien ici");
|
||||||
|
expect(removals.body[0].correctedTechStepKey).to.equal(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /training-data-snippet", () => {
|
||||||
|
it("aggregates the applied suggestions' synonyms into a paste-ready block", async function () {
|
||||||
|
if (!adminSecretConfigured) {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||||
|
(this as any).skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await seedCorrectionAndSuggestion({
|
||||||
|
clause: "Faire mijoter",
|
||||||
|
correctedKey: "simmer",
|
||||||
|
withSuggestion: { status: "applied", synonyms: ["frémir", "réduire à feu doux"] },
|
||||||
|
});
|
||||||
|
const agent = await adminAgent();
|
||||||
|
const res = await agent
|
||||||
|
.get("/admin/tech-steps/training-data-snippet")
|
||||||
|
.query({ techStepKey: "simmer", locale: "fr", status: "applied" });
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body.suggestionCount).to.equal(1);
|
||||||
|
expect(res.body.synonyms).to.deep.equal(["frémir", "réduire à feu doux"]);
|
||||||
|
expect(res.body.snippet).to.include('"frémir"');
|
||||||
|
expect(res.body.snippet).to.include('"synonyms": [');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /retrain", () => {
|
||||||
|
it("runs the F1 gate and returns its result shape (needs tech-step-intent-service)", async function () {
|
||||||
|
if (!adminSecretConfigured) {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||||
|
(this as any).skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.timeout(60000);
|
||||||
|
const agent = await adminAgent();
|
||||||
|
const res = await agent.post("/admin/tech-steps/retrain").send({});
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.have.keys([
|
||||||
|
"f1",
|
||||||
|
"precision",
|
||||||
|
"recall",
|
||||||
|
"minF1",
|
||||||
|
"gatePassed",
|
||||||
|
"backfilled",
|
||||||
|
"marked",
|
||||||
|
]);
|
||||||
|
expect(res.body.minF1).to.equal(0.8);
|
||||||
|
if (res.body.gatePassed) {
|
||||||
|
expect(res.body.backfilled).to.have.keys(["total", "changed"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 409 RETRAIN_ALREADY_RUNNING while one is in flight", async function () {
|
||||||
|
if (!adminSecretConfigured) {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||||
|
(this as any).skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.timeout(60000);
|
||||||
|
const agent = await adminAgent();
|
||||||
|
const first = agent.post("/admin/tech-steps/retrain").send({});
|
||||||
|
// Let the first handler acquire the process-wide lock before the second starts.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
const second = await agent.post("/admin/tech-steps/retrain").send({});
|
||||||
|
expect(second.status).to.equal(409);
|
||||||
|
expect(second.body.code).to.equal(ErrorCode.RETRAIN_ALREADY_RUNNING);
|
||||||
|
await first;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -46,7 +46,8 @@
|
||||||
"TECH_STEP_NOT_FOUND": "Cette technique n'existe pas",
|
"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": {
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue