f4b806a538
The reviewer host's collector.sqlite was created by an earlier build, before the `kind` column. CREATE TABLE IF NOT EXISTS shapes only a new database, so every query naming the column failed: the collector's /health (container unhealthy), every booth ingest, and the trainer's readiness — whose stdlib server printed the traceback and dropped the socket, which the collector could only render as "trainer not reachable: fetch failed". Nine days like that. - CollectorDb.#migrate(): PRAGMA table_info against the list of columns added since the first deploy; ALTER TABLE ADD COLUMN for each missing one (all nullable or defaulted). Append to that list whenever a column joins the CREATE. Test replays the original schema: health, ingest, stats, a legacy row reads back with the defaults. - Trainer Handler._guarded(): any unexpected exception → 500 JSON naming it, never a dropped connection; /health keeps answering. Test drives readiness against an old-schema DB. - The collector's training status proxy includes the trainer's error text. Wiki: the incident and the schema rule (vision-review-outbox), what the message means (bodytype-classifier-training), log. Deploy: the new collector migrates on start; nothing manual. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
220 lines
9.5 KiB
TypeScript
220 lines
9.5 KiB
TypeScript
import Database from "better-sqlite3";
|
|
import type { VehicleClass } from "@parking/shared";
|
|
|
|
// One table. Each row is one booth decision: what the camera saw, what the operator
|
|
// chose, and (once reviewed) what a trusted person says the vehicle is. The crop itself
|
|
// lives on disk beside the DB (crops/<booth>/<item>.jpg) so the trainer on the same host
|
|
// reads it straight off the volume.
|
|
|
|
export interface ItemRow {
|
|
id: string;
|
|
booth: string;
|
|
/** "wash" = a desk decision (operator fields set); "entry" = a sampled entry read (pure
|
|
* training material: crop + the camera's class, operator fields empty). */
|
|
kind: "wash" | "entry";
|
|
orderRef: string;
|
|
at: string;
|
|
operatorRef: string;
|
|
operatorCategoryId: string;
|
|
operatorCategoryName: string;
|
|
/** The vision classes the operator's category covers at that site (its mapping) — what
|
|
* lets a reviewer's CLASS be compared with an operator's CATEGORY. JSON array. */
|
|
operatorClasses: string;
|
|
service: string;
|
|
visionClass: string;
|
|
visionConfidence: number;
|
|
visionCategoryId: string | null;
|
|
downgraded: number;
|
|
imageWidth: number;
|
|
imageHeight: number;
|
|
plateBlurred: number;
|
|
imagePath: string;
|
|
receivedAt: string;
|
|
reviewLabel: string | null; // a VehicleClass, or "unusable"
|
|
reviewedAt: string | null;
|
|
reviewer: string | null;
|
|
}
|
|
|
|
export type ReviewVerdict = VehicleClass | "unusable";
|
|
|
|
export class CollectorDb {
|
|
readonly #db: Database.Database;
|
|
|
|
constructor(file: string) {
|
|
this.#db = new Database(file);
|
|
this.#db.pragma("journal_mode = WAL");
|
|
this.#db.exec(`
|
|
CREATE TABLE IF NOT EXISTS items (
|
|
id TEXT PRIMARY KEY,
|
|
booth TEXT NOT NULL,
|
|
kind TEXT NOT NULL DEFAULT 'wash',
|
|
order_ref TEXT NOT NULL,
|
|
at TEXT NOT NULL,
|
|
operator_ref TEXT NOT NULL DEFAULT '',
|
|
operator_category_id TEXT NOT NULL DEFAULT '',
|
|
operator_category_name TEXT NOT NULL DEFAULT '',
|
|
operator_classes TEXT NOT NULL DEFAULT '[]',
|
|
service TEXT NOT NULL,
|
|
vision_class TEXT NOT NULL,
|
|
vision_confidence REAL NOT NULL,
|
|
vision_category_id TEXT,
|
|
downgraded INTEGER NOT NULL DEFAULT 0,
|
|
image_width INTEGER NOT NULL,
|
|
image_height INTEGER NOT NULL,
|
|
plate_blurred INTEGER NOT NULL,
|
|
image_path TEXT NOT NULL,
|
|
received_at TEXT NOT NULL,
|
|
review_label TEXT,
|
|
reviewed_at TEXT,
|
|
reviewer TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS items_pending ON items (reviewed_at, received_at);
|
|
CREATE INDEX IF NOT EXISTS items_booth ON items (booth, received_at);
|
|
`);
|
|
this.#migrate();
|
|
}
|
|
|
|
/** Columns added after the first deploy, with the DDL that adds them to an EXISTING
|
|
* table. `CREATE TABLE IF NOT EXISTS` above only shapes a NEW database; a volume that
|
|
* was created by an earlier build keeps its old columns, and every query naming a new
|
|
* one then fails ("no such column: kind" — art-docker-station, 2026-09-16: the
|
|
* collector's /health, every ingest, and the trainer's readiness all broke on a DB
|
|
* from before `kind`). Each entry must be addable to a populated table, i.e. nullable
|
|
* or carrying a DEFAULT. Append here whenever a column joins the CREATE above. */
|
|
static readonly #ADDED_COLUMNS: ReadonlyArray<readonly [name: string, ddl: string]> = [
|
|
["kind", "TEXT NOT NULL DEFAULT 'wash'"],
|
|
["operator_ref", "TEXT NOT NULL DEFAULT ''"],
|
|
["operator_category_id", "TEXT NOT NULL DEFAULT ''"],
|
|
["operator_category_name", "TEXT NOT NULL DEFAULT ''"],
|
|
["operator_classes", "TEXT NOT NULL DEFAULT '[]'"],
|
|
["vision_category_id", "TEXT"],
|
|
["downgraded", "INTEGER NOT NULL DEFAULT 0"],
|
|
];
|
|
|
|
/** Bring an existing `items` table up to the current column set (idempotent). */
|
|
#migrate(): void {
|
|
const present = new Set(
|
|
(this.#db.prepare("PRAGMA table_info(items)").all() as { name: string }[]).map((c) => c.name),
|
|
);
|
|
for (const [name, ddl] of CollectorDb.#ADDED_COLUMNS) {
|
|
if (!present.has(name)) this.#db.exec(`ALTER TABLE items ADD COLUMN ${name} ${ddl}`);
|
|
}
|
|
}
|
|
|
|
/** The current column names of `items` (for tests and diagnostics). */
|
|
columns(): string[] {
|
|
return (this.#db.prepare("PRAGMA table_info(items)").all() as { name: string }[]).map((c) => c.name);
|
|
}
|
|
|
|
close(): void {
|
|
this.#db.close();
|
|
}
|
|
|
|
static #map(r: Record<string, unknown>): ItemRow {
|
|
return {
|
|
id: r.id as string,
|
|
booth: r.booth as string,
|
|
kind: r.kind === "entry" ? "entry" : "wash",
|
|
orderRef: r.order_ref as string,
|
|
at: r.at as string,
|
|
operatorRef: r.operator_ref as string,
|
|
operatorCategoryId: r.operator_category_id as string,
|
|
operatorCategoryName: r.operator_category_name as string,
|
|
operatorClasses: r.operator_classes as string,
|
|
service: r.service as string,
|
|
visionClass: r.vision_class as string,
|
|
visionConfidence: r.vision_confidence as number,
|
|
visionCategoryId: (r.vision_category_id as string | null) ?? null,
|
|
downgraded: r.downgraded as number,
|
|
imageWidth: r.image_width as number,
|
|
imageHeight: r.image_height as number,
|
|
plateBlurred: r.plate_blurred as number,
|
|
imagePath: r.image_path as string,
|
|
receivedAt: r.received_at as string,
|
|
reviewLabel: (r.review_label as string | null) ?? null,
|
|
reviewedAt: (r.reviewed_at as string | null) ?? null,
|
|
reviewer: (r.reviewer as string | null) ?? null,
|
|
};
|
|
}
|
|
|
|
get(id: string): ItemRow | null {
|
|
const r = this.#db.prepare("SELECT * FROM items WHERE id = ?").get(id) as Record<string, unknown> | undefined;
|
|
return r ? CollectorDb.#map(r) : null;
|
|
}
|
|
|
|
insert(row: Omit<ItemRow, "reviewLabel" | "reviewedAt" | "reviewer">): void {
|
|
this.#db
|
|
.prepare(
|
|
`INSERT INTO items (id, booth, kind, order_ref, at, operator_ref, operator_category_id, operator_category_name,
|
|
operator_classes, service, vision_class, vision_confidence, vision_category_id, downgraded,
|
|
image_width, image_height, plate_blurred, image_path, received_at)
|
|
VALUES (@id, @booth, @kind, @orderRef, @at, @operatorRef, @operatorCategoryId, @operatorCategoryName,
|
|
@operatorClasses, @service, @visionClass, @visionConfidence, @visionCategoryId, @downgraded,
|
|
@imageWidth, @imageHeight, @plateBlurred, @imagePath, @receivedAt)`,
|
|
)
|
|
.run(row);
|
|
}
|
|
|
|
list(status: "pending" | "reviewed", limit: number, booth?: string): ItemRow[] {
|
|
const where = [status === "pending" ? "reviewed_at IS NULL" : "reviewed_at IS NOT NULL"];
|
|
const params: unknown[] = [];
|
|
if (booth) {
|
|
where.push("booth = ?");
|
|
params.push(booth);
|
|
}
|
|
const order = status === "pending" ? "received_at ASC" : "reviewed_at DESC";
|
|
const rows = this.#db
|
|
.prepare(`SELECT * FROM items WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT ?`)
|
|
.all(...params, limit) as Record<string, unknown>[];
|
|
return rows.map((r) => CollectorDb.#map(r));
|
|
}
|
|
|
|
review(id: string, label: ReviewVerdict, reviewer: string): ItemRow | null {
|
|
this.#db
|
|
.prepare("UPDATE items SET review_label = ?, reviewed_at = ?, reviewer = ? WHERE id = ?")
|
|
.run(label, new Date().toISOString(), reviewer, id);
|
|
return this.get(id);
|
|
}
|
|
|
|
/** Per booth: received / pending / reviewed. Per operator (booth + hash): how often the
|
|
* reviewer's class fell inside the operator's chosen category (agree) or outside
|
|
* (disagree) — the honest-mistake / fraud rate the outbox exists for. */
|
|
stats(): {
|
|
booths: { booth: string; received: number; pending: number; reviewed: number; entries: number }[];
|
|
operators: { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }[];
|
|
} {
|
|
const booths = this.#db
|
|
.prepare(
|
|
`SELECT booth, COUNT(*) AS received,
|
|
SUM(CASE WHEN reviewed_at IS NULL THEN 1 ELSE 0 END) AS pending,
|
|
SUM(CASE WHEN reviewed_at IS NOT NULL THEN 1 ELSE 0 END) AS reviewed,
|
|
SUM(CASE WHEN kind = 'entry' THEN 1 ELSE 0 END) AS entries
|
|
FROM items GROUP BY booth ORDER BY booth`,
|
|
)
|
|
.all() as { booth: string; received: number; pending: number; reviewed: number; entries: number }[];
|
|
// Operator agreement is a WASH thing — an entry sample has no operator decision.
|
|
const reviewed = this.#db
|
|
.prepare("SELECT booth, operator_ref, operator_classes, review_label FROM items WHERE reviewed_at IS NOT NULL AND kind = 'wash'")
|
|
.all() as { booth: string; operator_ref: string; operator_classes: string; review_label: string }[];
|
|
const ops = new Map<string, { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }>();
|
|
for (const r of reviewed) {
|
|
const key = `${r.booth} ${r.operator_ref}`;
|
|
let o = ops.get(key);
|
|
if (!o) ops.set(key, (o = { booth: r.booth, operatorRef: r.operator_ref, reviewed: 0, agree: 0, disagree: 0, unusable: 0 }));
|
|
o.reviewed += 1;
|
|
if (r.review_label === "unusable") o.unusable += 1;
|
|
else if ((JSON.parse(r.operator_classes) as string[]).includes(r.review_label)) o.agree += 1;
|
|
else o.disagree += 1;
|
|
}
|
|
return { booths, operators: [...ops.values()].sort((a, b) => b.disagree - a.disagree) };
|
|
}
|
|
|
|
/** Reviewed, usable rows — the training set. */
|
|
labelled(): ItemRow[] {
|
|
const rows = this.#db
|
|
.prepare("SELECT * FROM items WHERE reviewed_at IS NOT NULL AND review_label != 'unusable' ORDER BY reviewed_at")
|
|
.all() as Record<string, unknown>[];
|
|
return rows.map((r) => CollectorDb.#map(r));
|
|
}
|
|
}
|