feat(trainer): training from the collector UI — the trainer becomes a job service, the review page gains a Training section
Trainer: `parking-trainer serve` — a stdlib HTTP job API on the compose network (never
published): /health, /readiness, /versions, /versions/<v>/report, /jobs. One job at a
time; each job runs the CLI as a subprocess with its output captured, state + log
persisted under /out/jobs/ so a restart keeps history. `publish` takes its URL from
TRAINER_PUBLISH_URL. Dockerfile: CMD serve, EXPOSE 8091, healthcheck.
Collector: COLLECTOR_TRAINER_URL + /api/training/{status,jobs,jobs/:id,versions/:v/report}
— a reviewer-gated proxy that forwards a fixed set of paths and whitelisted knobs and
passes the trainer's status codes through (409 while a job runs; 503 unconfigured, 502
unreachable). /review gains the Training section: labels per class vs the minimum with
Train disabled until two classes clear it, mode / backbone / floor, the running job's
live log, the versions with Report / Evaluate / Publish (publish confirms), and the
reminder that pinning stays a git commit. Fixed on the way: an apostrophe in the page's
inline script broke the whole page — a test now parses the script.
Compose: `trainer` is a service (restart: unless-stopped, read-only data volume, its own
trainer-out volume), the `train` profile and TRAINER_OUT are gone; the Docker-socket
route was rejected (root on the host for a service booths upload to). Verified with both
images running together: a Train started through the proxy finished, version and report
came back, the page rendered.
Wiki: bodytype-classifier-training (loop, running it, operating notes superseded),
vision-review-outbox, fleet-deployment-komodo, log.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -15,6 +15,8 @@ import { reviewPage } from "./review-page.js";
|
||||
// /review + /api/* the reviewer's screen (HTTP Basic, one login)
|
||||
// GET /export/labels.csv the training set: reviewed, usable rows (crops sit beside it on
|
||||
// the volume, so the trainer on this host reads them directly)
|
||||
// /api/training/* the Training section: a thin proxy to the trainer's job API on
|
||||
// the compose network (never published), behind the reviewer login
|
||||
// It deliberately has no fleet features and no path back into a booth.
|
||||
|
||||
/** The package's `meta` part, as the booth sends it (review-outbox.ts). */
|
||||
@@ -230,6 +232,55 @@ export async function buildCollector(cfg: CollectorConfig, opts: { dbFile?: stri
|
||||
return reply.type("text/csv; charset=utf-8").header("content-disposition", 'attachment; filename="labels.csv"').send([head, ...lines].join("\n") + "\n");
|
||||
});
|
||||
|
||||
// --- Training (proxy to the trainer's job API) ----------------------------------------
|
||||
// The trainer is a sibling container reading the same volume; it is reachable only on the
|
||||
// compose network, so the reviewer's login here is the only gate. The proxy forwards a
|
||||
// fixed set of paths and passes the trainer's status codes through (409 = a job runs).
|
||||
const trainer = cfg.trainerUrl;
|
||||
async function viaTrainer(reply: FastifyReply, tpath: string, init?: RequestInit): Promise<unknown> {
|
||||
if (!trainer) return reply.code(503).send({ error: "trainer not configured" });
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetch(trainer + tpath, { ...init, signal: AbortSignal.timeout(15_000) });
|
||||
} catch (err) {
|
||||
return reply.code(502).send({ error: `trainer unreachable: ${(err as Error).message}` });
|
||||
}
|
||||
const ctype = r.headers.get("content-type") ?? "application/json";
|
||||
return reply.code(r.status).type(ctype).send(Buffer.from(await r.arrayBuffer()));
|
||||
}
|
||||
app.get("/api/training/status", { preHandler: requireReviewer }, async (_req, reply) => {
|
||||
if (!trainer) return { configured: false };
|
||||
try {
|
||||
const get = async (p: string) => {
|
||||
const r = await fetch(trainer + p, { signal: AbortSignal.timeout(15_000) });
|
||||
if (!r.ok) throw new Error(`${p} → HTTP ${r.status}`);
|
||||
return r.json() as Promise<Record<string, unknown>>;
|
||||
};
|
||||
const [health, readiness, versions, jobs] = await Promise.all([get("/health"), get("/readiness"), get("/versions"), get("/jobs")]);
|
||||
return { configured: true, reachable: true, health, readiness, versions: versions.versions, jobs: jobs.jobs, current: jobs.current };
|
||||
} catch (err) {
|
||||
return reply.code(200).send({ configured: true, reachable: false, error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
app.post<{ Body: Record<string, unknown> }>("/api/training/jobs", { preHandler: requireReviewer }, async (req, reply) => {
|
||||
const b = req.body && typeof req.body === "object" ? req.body : {};
|
||||
const kind = b.kind;
|
||||
if (kind !== "train" && kind !== "evaluate" && kind !== "publish") return reply.code(400).send({ error: "kind must be train, evaluate or publish" });
|
||||
// Only the knobs the UI offers cross over; the trainer validates their values.
|
||||
const allowed = ["kind", "mode", "backbone", "minAccuracy", "minPerClass", "epochs", "version"];
|
||||
const body: Record<string, unknown> = {};
|
||||
for (const k of allowed) if (b[k] !== undefined) body[k] = b[k];
|
||||
return viaTrainer(reply, "/jobs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
||||
});
|
||||
app.get<{ Params: { id: string } }>("/api/training/jobs/:id", { preHandler: requireReviewer }, async (req, reply) => {
|
||||
if (!ID_RE.test(req.params.id)) return reply.code(400).send({ error: "bad job id" });
|
||||
return viaTrainer(reply, `/jobs/${encodeURIComponent(req.params.id)}`);
|
||||
});
|
||||
app.get<{ Params: { v: string } }>("/api/training/versions/:v/report", { preHandler: requireReviewer }, async (req, reply) => {
|
||||
if (!ID_RE.test(req.params.v)) return reply.code(400).send({ error: "bad version" });
|
||||
return viaTrainer(reply, `/versions/${encodeURIComponent(req.params.v)}/report`);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user