feat(carwash): review outbox, booth side — plate-blurred vehicle crop + the operator's choice, queued for a trusted remote reviewer
The operator's category choice is a hypothesis, not truth (user, 2026-09-06): each wash order with a vehicle read queues a package for a trusted reviewer over the private overlay (Netbird); the verdict becomes the phase-B training label and the per-operator error rate. wiki/concepts/vision-review-outbox.md. - Boxes: the vision service returns the vehicle bbox; snapshot.ts stores the vehicle and plate boxes on the read as FRACTIONS of the analysed frame (the stored snapshot is a downscaled copy); vehicleForIdentity() returns them. - carwash_review_outbox (migration 0031) + review-outbox.ts: crop = detector box + 8 % margin, ≤ 640 px, plate blurred in place from the plate box; payload carries a pseudonymous booth id and a keyed operator hash — no site name, no plate, no OSD, no bystanders; multipart POST with a per-booth bearer; 2xx → sent (image dropped); 400/404/413/415/422 → abandoned; anything else → backoff 1 min·2^n capped 6 h; voided orders and items older than 14 days abandoned unsent. Nothing queued while unconfigured. - Enqueue is fire-and-forget off the intake path in createOrder; the loop runs every CARWASH_REVIEW_INTERVAL_SEC (60) and stops on close. - GET /api/carwash/review/status (site:read) + a "Remote review" line in Setup → Car wash. - Env CARWASH_REVIEW_URL / _TOKEN / _BOOTH_ID (all three or off) documented in .env.example and forwarded by compose. - Tests: review-outbox.test.ts (crop + blur on a synthetic frame, config/pseudonyms, queue/drain/backoff/abandon, through the app). Wiki: new concept page, index, venue-modules As built, log. The collector is not built. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -87,3 +87,15 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos
|
|||||||
# is never entitled to a module its Komodo stack env does not name. Required modules
|
# is never entitled to a module its Komodo stack env does not name. Required modules
|
||||||
# (parking) are always on. See wiki/decisions/venue-modules.md.
|
# (parking) are always on. See wiki/decisions/venue-modules.md.
|
||||||
#MODULES_ENTITLED=parking,validation
|
#MODULES_ENTITLED=parking,validation
|
||||||
|
|
||||||
|
# Car Wash review outbox (wiki/concepts/vision-review-outbox.md) -------------------------
|
||||||
|
# The operator's category choice is a hypothesis: each wash order with a vehicle read queues
|
||||||
|
# the vehicle CROP (plate blurred) + the choice for a trusted remote reviewer, drained one-way
|
||||||
|
# over the private overlay (Netbird). All three or off. URL = the collector's ingest endpoint
|
||||||
|
# (reachable only over the overlay); TOKEN = this booth's own bearer token; BOOTH_ID = a
|
||||||
|
# pseudonymous label the reviewer maps to a site (NEVER the site name — it travels with every
|
||||||
|
# item). Set in the Komodo stack env, per booth. Nothing is queued while off.
|
||||||
|
# CARWASH_REVIEW_URL=
|
||||||
|
# CARWASH_REVIEW_TOKEN=
|
||||||
|
# CARWASH_REVIEW_BOOTH_ID=
|
||||||
|
# CARWASH_REVIEW_INTERVAL_SEC=60
|
||||||
|
|||||||
@@ -597,7 +597,7 @@ describe("vision category — advisory, flagged, never authoritative", () => {
|
|||||||
await openSession("T-V1");
|
await openSession("T-V1");
|
||||||
seeVehicle("T-V1", "suv", 0.91);
|
seeVehicle("T-V1", "suv", 0.91);
|
||||||
const look = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V1", headers: { cookie: a.cookie } })).json();
|
const look = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V1", headers: { cookie: a.cookie } })).json();
|
||||||
expect(look.vision).toEqual({ bodyType: "suv", confidence: 0.91, snapshotId: "snap-1" });
|
expect(look.vision).toMatchObject({ bodyType: "suv", confidence: 0.91, snapshotId: "snap-1" });
|
||||||
expect(look.suggestedCategoryId).toBe(ids.suv);
|
expect(look.suggestedCategoryId).toBe(ids.suv);
|
||||||
// Unmapped class → shown, nothing suggested.
|
// Unmapped class → shown, nothing suggested.
|
||||||
await openSession("T-V2");
|
await openSession("T-V2");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ServerModule } from "../index.js";
|
import type { ServerModule } from "../index.js";
|
||||||
|
import { ReviewOutbox, reviewUploadConfigFromEnv } from "./review-outbox.js";
|
||||||
import { carwashRoutes } from "./routes.js";
|
import { carwashRoutes } from "./routes.js";
|
||||||
import { CarwashService } from "./service.js";
|
import { CarwashService } from "./service.js";
|
||||||
|
|
||||||
@@ -10,10 +11,18 @@ import { CarwashService } from "./service.js";
|
|||||||
export const carwashModule: ServerModule = {
|
export const carwashModule: ServerModule = {
|
||||||
id: "carwash",
|
id: "carwash",
|
||||||
async register(app, deps) {
|
async register(app, deps) {
|
||||||
const service = new CarwashService(deps, app.log);
|
// The review outbox (wiki/concepts/vision-review-outbox.md): on when the stack env
|
||||||
|
// names a collector URL, a per-booth token and a pseudonymous booth id; off = no
|
||||||
|
// queueing at all. One-way, background, never on the intake path.
|
||||||
|
const cfg = reviewUploadConfigFromEnv();
|
||||||
|
const outbox = new ReviewOutbox(deps.db, app.log, cfg);
|
||||||
|
app.log.info(cfg ? `carwash review upload: on → ${new URL(cfg.url).host} as ${cfg.boothId}` : "carwash review upload: off");
|
||||||
|
outbox.start();
|
||||||
|
app.addHook("onClose", async () => outbox.stop());
|
||||||
|
const service = new CarwashService(deps, app.log, outbox);
|
||||||
// A wash ordered with payAt = "booth" is a charge line on the parking settlement;
|
// A wash ordered with payAt = "booth" is a charge line on the parking settlement;
|
||||||
// the core calls back after the payment is signed so the order is marked paid.
|
// the core calls back after the payment is signed so the order is marked paid.
|
||||||
deps.payStation.registerChargeProvider(service.chargeProvider());
|
deps.payStation.registerChargeProvider(service.chargeProvider());
|
||||||
await carwashRoutes(app, deps, service);
|
await carwashRoutes(app, deps, service, outbox);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { carwashOrders, carwashReviewOutbox, deviceEvents, snapshots, type Db } from "@parking/db";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "../../server.js";
|
||||||
|
import { login, makeLog, minutesAgo, seedTariff, seedUser, silentLogger } from "../../test-helpers.js";
|
||||||
|
import { EXPIRE_DAYS, ReviewOutbox, makeReviewCrop, operatorRef, reviewUploadConfigFromEnv } from "./review-outbox.js";
|
||||||
|
|
||||||
|
// The review outbox, booth side (wiki/concepts/vision-review-outbox.md): a plate-blurred
|
||||||
|
// vehicle crop + the operator's choice, queued off the intake path, drained one-way with
|
||||||
|
// backoff, never blocking the wash, never naming the site.
|
||||||
|
|
||||||
|
/** A 400×300 frame: grey ground, a red "car" block, a white "plate" strip inside it. */
|
||||||
|
async function frame(): Promise<Buffer> {
|
||||||
|
return sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 90, g: 90, b: 90 } } })
|
||||||
|
.composite([
|
||||||
|
{ input: { create: { width: 200, height: 120, channels: 3, background: { r: 200, g: 30, b: 30 } } }, left: 100, top: 100 },
|
||||||
|
{ input: { create: { width: 60, height: 16, channels: 3, background: { r: 255, g: 255, b: 255 } } }, left: 170, top: 190 },
|
||||||
|
])
|
||||||
|
.jpeg()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
const CAR = { x1: 100 / 400, y1: 100 / 300, x2: 300 / 400, y2: 220 / 300 };
|
||||||
|
const PLATE = { x1: 170 / 400, y1: 190 / 300, x2: 230 / 400, y2: 206 / 300 };
|
||||||
|
|
||||||
|
/** Mean GREEN over a region — the white plate reads 255, the red car around it 30, so a
|
||||||
|
* blurred plate drops far below 255 as the red bleeds in. */
|
||||||
|
async function meanGreen(buf: Buffer, region: { left: number; top: number; width: number; height: number }): Promise<number> {
|
||||||
|
const { data, info } = await sharp(buf).extract(region).raw().toBuffer({ resolveWithObject: true });
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 1; i < data.length; i += info.channels) sum += data[i]!;
|
||||||
|
return sum / (data.length / info.channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("makeReviewCrop", () => {
|
||||||
|
it("cuts the vehicle (with margin), blurs the plate inside it, caps the edge", async () => {
|
||||||
|
const shot = await frame();
|
||||||
|
const crop = await makeReviewCrop(shot, CAR, PLATE);
|
||||||
|
expect(crop.plateBlurred).toBe(true);
|
||||||
|
// Box 200×120 + 8 % margin each side ≈ 232×139; no upscaling.
|
||||||
|
expect(crop.width).toBeGreaterThanOrEqual(228);
|
||||||
|
expect(crop.width).toBeLessThanOrEqual(236);
|
||||||
|
expect(crop.height).toBeGreaterThanOrEqual(135);
|
||||||
|
// The white plate is gone: over the plate strip (crop coords: the frame's 170..230 ×
|
||||||
|
// 190..206 shifted by the crop origin 84,90) the same region cut straight from the
|
||||||
|
// frame is white, the review crop is the red bleeding in.
|
||||||
|
const plain = await sharp(shot).extract({ left: 84, top: 90, width: crop.width, height: crop.height }).jpeg().toBuffer();
|
||||||
|
const strip = { left: 170 - 84, top: 190 - 90, width: 60, height: 16 };
|
||||||
|
expect(await meanGreen(plain, strip)).toBeGreaterThan(240);
|
||||||
|
expect(await meanGreen(crop.bytes, strip)).toBeLessThan(180);
|
||||||
|
// Without a plate box: same crop, nothing blurred.
|
||||||
|
const noPlate = await makeReviewCrop(shot, CAR, null);
|
||||||
|
expect(noPlate.plateBlurred).toBe(false);
|
||||||
|
// A big frame is capped to the max edge.
|
||||||
|
const big = await sharp({ create: { width: 2560, height: 1440, channels: 3, background: "#444" } }).jpeg().toBuffer();
|
||||||
|
const capped = await makeReviewCrop(big, { x1: 0, y1: 0, x2: 1, y2: 1 }, null);
|
||||||
|
expect(Math.max(capped.width, capped.height)).toBe(640);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("config + pseudonyms", () => {
|
||||||
|
it("needs url, token and booth id together; the operator ref is a keyed hash", () => {
|
||||||
|
expect(reviewUploadConfigFromEnv({})).toBeNull();
|
||||||
|
expect(reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t" })).toBeNull();
|
||||||
|
const cfg = reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t", CARWASH_REVIEW_BOOTH_ID: "b7", CARWASH_REVIEW_INTERVAL_SEC: "5" });
|
||||||
|
expect(cfg).toMatchObject({ boothId: "b7", intervalSec: 60 }); // below the 10 s floor → default
|
||||||
|
expect(operatorRef("b7", "lavazhier")).toHaveLength(16);
|
||||||
|
expect(operatorRef("b7", "lavazhier")).not.toBe(operatorRef("b8", "lavazhier"));
|
||||||
|
expect(operatorRef("b7", "lavazhier")).not.toContain("lavazhier");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("queue + drain", () => {
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
});
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
const cfg = { url: "https://collector.overlay/ingest", token: "secret-1", boothId: "booth-7", intervalSec: 60 };
|
||||||
|
const read = { bodyType: "car" as const, confidence: 0.86, snapshotId: "snap-1", box: CAR, plateBox: PLATE };
|
||||||
|
const item = { orderId: "o-1", createdAt: "2026-09-06T10:00:00.000Z", createdBy: "lavazhier", categoryId: "car", categoryName: "Vetura", serviceName: "Standard", visionCategoryId: "car", downgraded: false };
|
||||||
|
|
||||||
|
async function seed(): Promise<void> {
|
||||||
|
db.insert(snapshots).values({ id: "snap-1", direction: "entry", identity: "T-1", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
|
||||||
|
db.insert(carwashOrders).values({
|
||||||
|
id: "o-1", identity: "T-1", plate: null, categoryId: "car", categoryName: "Vetura", serviceId: "std", serviceName: "Standard",
|
||||||
|
priceMinor: 100, currency: "ALL", payAt: "booth", status: "open", createdAt: item.createdAt, createdBy: "lavazhier",
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("enqueues a crop + a payload with no site name, no plate, no operator name; drains with a multipart POST; drops the image once sent", async () => {
|
||||||
|
await seed();
|
||||||
|
const calls: { url: string; init: RequestInit }[] = [];
|
||||||
|
const fetchFn = vi.fn(async (url: string, init: RequestInit) => {
|
||||||
|
calls.push({ url, init });
|
||||||
|
return new Response("ok", { status: 200 });
|
||||||
|
});
|
||||||
|
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
|
||||||
|
expect(await ob.enqueue(item, read)).toBe(true);
|
||||||
|
const row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||||
|
expect(row.status).toBe("queued");
|
||||||
|
expect(row.image!.length).toBeGreaterThan(500);
|
||||||
|
expect(row.payload).toMatchObject({ v: 1, booth: "booth-7", order: "o-1", operatorCategory: { id: "car", name: "Vetura" }, vision: { class: "car", confidence: 0.86 }, downgraded: false, image: { plateBlurred: true } });
|
||||||
|
expect(JSON.stringify(row.payload)).not.toContain("lavazhier");
|
||||||
|
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 1, failed: 0, deferred: 0 });
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.url).toBe(cfg.url);
|
||||||
|
expect((calls[0]!.init.headers as Record<string, string>).authorization).toBe("Bearer secret-1");
|
||||||
|
const form = calls[0]!.init.body as FormData;
|
||||||
|
expect(JSON.parse(form.get("meta") as string).item).toBe(row.id);
|
||||||
|
expect((form.get("image") as File).type).toBe("image/jpeg");
|
||||||
|
const after = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||||
|
expect(after.status).toBe("sent");
|
||||||
|
expect(after.image).toBeNull();
|
||||||
|
expect(after.sentAt).toBeTruthy();
|
||||||
|
expect(ob.status()).toMatchObject({ enabled: true, boothId: "booth-7", queued: 0, sent: 1, failed: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defers with backoff on collector/network trouble, abandons on a rejection, a void or expiry, skips without a box", async () => {
|
||||||
|
await seed();
|
||||||
|
let status = 503;
|
||||||
|
const fetchFn = vi.fn(async () => (status === 0 ? Promise.reject(new Error("ECONNREFUSED")) : new Response("", { status })));
|
||||||
|
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
|
||||||
|
await ob.enqueue(item, read);
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
|
||||||
|
let row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||||
|
expect(row).toMatchObject({ status: "queued", attempts: 1, lastError: "HTTP 503" });
|
||||||
|
expect(Date.parse(row.nextAttemptAt!)).toBeGreaterThan(Date.now() + 60_000);
|
||||||
|
// Not due yet → untouched.
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 0 });
|
||||||
|
// Due again: a network error defers too; a 422 abandons.
|
||||||
|
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
|
||||||
|
status = 0;
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
|
||||||
|
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
|
||||||
|
status = 422;
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||||
|
row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||||
|
expect(row).toMatchObject({ status: "failed", lastError: "rejected: HTTP 422" });
|
||||||
|
expect(row.image).toBeNull();
|
||||||
|
|
||||||
|
// A voided order is not a sample.
|
||||||
|
status = 200;
|
||||||
|
await ob.enqueue({ ...item, orderId: "o-1" }, read);
|
||||||
|
db.update(carwashOrders).set({ status: "void" }).run();
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||||
|
// Expired items are abandoned without a request.
|
||||||
|
await ob.enqueue(item, read);
|
||||||
|
db.update(carwashReviewOutbox).set({ createdAt: new Date(Date.now() - (EXPIRE_DAYS + 1) * 86_400_000).toISOString() }).where(eq(carwashReviewOutbox.status, "queued")).run();
|
||||||
|
db.update(carwashOrders).set({ status: "open" }).run();
|
||||||
|
const before = fetchFn.mock.calls.length;
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||||
|
expect(fetchFn.mock.calls.length).toBe(before);
|
||||||
|
expect(ob.status().failed).toBe(3);
|
||||||
|
|
||||||
|
// No vehicle box, no snapshot, or upload off → nothing queued.
|
||||||
|
expect(await ob.enqueue(item, { ...read, box: null })).toBe(false);
|
||||||
|
expect(await ob.enqueue(item, { ...read, snapshotId: "gone" })).toBe(false);
|
||||||
|
expect(await new ReviewOutbox(db, silentLogger(), null, fetchFn).enqueue(item, read)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
import { eq } from "@parking/db";
|
||||||
|
|
||||||
|
describe("through the app", () => {
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
const saved = { ...process.env };
|
||||||
|
beforeEach(async () => {
|
||||||
|
delete process.env.MODULES_ENTITLED;
|
||||||
|
process.env.CARWASH_REVIEW_URL = "https://collector.overlay/ingest";
|
||||||
|
process.env.CARWASH_REVIEW_TOKEN = "tok";
|
||||||
|
process.env.CARWASH_REVIEW_BOOTH_ID = "booth-9";
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
for (const k of ["CARWASH_REVIEW_URL", "CARWASH_REVIEW_TOKEN", "CARWASH_REVIEW_BOOTH_ID"]) {
|
||||||
|
if (saved[k] === undefined) delete process.env[k];
|
||||||
|
else process.env[k] = saved[k];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a wash intake with a vehicle read queues a review item; the status route reports it", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const a = await login(app, username, password);
|
||||||
|
const hdrs = { cookie: a.cookie, "x-csrf-token": a.csrf };
|
||||||
|
seedTariff(db);
|
||||||
|
const s = (await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { categories: [{ name: "Vetura", visionClasses: ["car"] }], services: [{ name: "Standard" }], prices: [] } })).json();
|
||||||
|
const cat = s.categories[0].id, svc = s.services[0].id;
|
||||||
|
await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { prices: [{ categoryId: cat, serviceId: svc, priceMinor: 500 }] } });
|
||||||
|
await makeLog(db).append({ type: "vehicle_entry", source: "manual", identity: "T-R", occurredAt: minutesAgo(30), payload: { sessionRef: "T-R", category: "default" } });
|
||||||
|
db.insert(snapshots).values({ id: "snap-r", direction: "entry", identity: "T-R", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
|
||||||
|
db.insert(deviceEvents).values({
|
||||||
|
id: "read-r", deviceId: "cam-1", category: "camera", kind: "read",
|
||||||
|
detail: { identity: "T-R", direction: "entry", bodyType: "car", bodyConfidence: 0.9, snapshotId: "snap-r", vehicleBox: CAR, plateBox: PLATE },
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const order = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs, payload: { identity: "T-R", categoryId: cat, serviceId: svc } });
|
||||||
|
expect(order.statusCode).toBe(201);
|
||||||
|
// Enqueue is fire-and-forget: give the crop a moment.
|
||||||
|
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(1));
|
||||||
|
const status = (await app.inject({ method: "GET", url: "/api/carwash/review/status", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(status).toMatchObject({ enabled: true, boothId: "booth-9", queued: 1, sent: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { and, asc, carwashOrders, carwashReviewOutbox, eq, isNull, lte, or, snapshots, sql, type Db } from "@parking/db";
|
||||||
|
import type { NormBox, VehicleRead } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
|
||||||
|
// The Car Wash REVIEW OUTBOX — booth side (wiki/concepts/vision-review-outbox.md).
|
||||||
|
//
|
||||||
|
// The operator's category choice at intake is a HYPOTHESIS, not truth (the threat model:
|
||||||
|
// the operator may err or cheat). So every wash order that has a vehicle read queues a
|
||||||
|
// small package for a trusted remote reviewer: the vehicle CROP cut out of the entry
|
||||||
|
// snapshot with the plate BLURRED, the operator's choice, and what the camera thought.
|
||||||
|
// The reviewer's verdict becomes the training label for the body-type classifier (phase
|
||||||
|
// B) and, per operator, the honest-mistake / fraud rate.
|
||||||
|
//
|
||||||
|
// Rules that shape this file:
|
||||||
|
// - OFFLINE-FIRST: the wash never waits. Enqueue is fire-and-forget off the intake path;
|
||||||
|
// a background loop drains the queue when the private overlay (Netbird) is up, with
|
||||||
|
// backoff, and gives up loudly after EXPIRE_DAYS.
|
||||||
|
// - ONE-WAY: the booth POSTs; nothing ever comes back into the booth's decisions. The
|
||||||
|
// signed ledger stays the only record of what happened at the wash.
|
||||||
|
// - NOTHING THAT NAMES THE SITE LEAVES: only the crop (no walls, no camera OSD, no
|
||||||
|
// bystanders), the plate blurred in place, a per-booth pseudonymous id set at deploy,
|
||||||
|
// the operator as a keyed hash. The mapping back to people and places stays with the
|
||||||
|
// reviewer, off the collector.
|
||||||
|
// - THE NETWORK IS NOT THE AUTH: a per-booth bearer token on top of the overlay; the
|
||||||
|
// booth can do nothing at the collector but this one POST.
|
||||||
|
|
||||||
|
export interface ReviewUploadConfig {
|
||||||
|
/** The collector's ingest URL (reachable only over the overlay). */
|
||||||
|
readonly url: string;
|
||||||
|
/** Per-booth bearer token. */
|
||||||
|
readonly token: string;
|
||||||
|
/** Pseudonymous booth id — a label the reviewer maps to a site; never the site name. */
|
||||||
|
readonly boothId: string;
|
||||||
|
readonly intervalSec: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** From the server env (Komodo stack env). All three of URL, token and booth id, or off. */
|
||||||
|
export function reviewUploadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): ReviewUploadConfig | null {
|
||||||
|
const url = (env.CARWASH_REVIEW_URL ?? "").trim();
|
||||||
|
const token = (env.CARWASH_REVIEW_TOKEN ?? "").trim();
|
||||||
|
const boothId = (env.CARWASH_REVIEW_BOOTH_ID ?? "").trim();
|
||||||
|
if (!url || !token || !boothId) return null;
|
||||||
|
const raw = Number(env.CARWASH_REVIEW_INTERVAL_SEC ?? 60);
|
||||||
|
return { url, token, boothId, intervalSec: Number.isFinite(raw) && raw >= 10 ? raw : 60 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The crop's longest edge, in pixels — enough for a reviewer and a classifier, small
|
||||||
|
* enough that a day of washes is a few megabytes. */
|
||||||
|
export const CROP_MAX_EDGE = 640;
|
||||||
|
/** Margin around the detector's box, as a fraction of the box (context for the reviewer). */
|
||||||
|
const CROP_MARGIN = 0.08;
|
||||||
|
/** Items older than this are abandoned (failed "expired") — a booth cut off for two weeks
|
||||||
|
* should not resurface a fortnight of crops in one burst. */
|
||||||
|
export const EXPIRE_DAYS = 14;
|
||||||
|
/** Backoff: 1 min · 2^attempts, capped. */
|
||||||
|
const BACKOFF_BASE_MS = 60_000;
|
||||||
|
const BACKOFF_CAP_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const UPLOAD_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
/** What one order contributes to the package (the service hands this over at intake). */
|
||||||
|
export interface ReviewItemInput {
|
||||||
|
readonly orderId: string;
|
||||||
|
readonly createdAt: string;
|
||||||
|
readonly createdBy: string;
|
||||||
|
readonly categoryId: string;
|
||||||
|
readonly categoryName: string;
|
||||||
|
readonly serviceName: string;
|
||||||
|
readonly visionCategoryId: string | null;
|
||||||
|
readonly downgraded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cut the vehicle out of the snapshot and blur the plate inside it. Boxes are fractions
|
||||||
|
* of the frame, so this works on the stored (downscaled) copy. Returns a JPEG.
|
||||||
|
*/
|
||||||
|
export async function makeReviewCrop(
|
||||||
|
snapshotBytes: Buffer,
|
||||||
|
box: NormBox,
|
||||||
|
plateBox: NormBox | null | undefined,
|
||||||
|
): Promise<{ bytes: Buffer; width: number; height: number; plateBlurred: boolean }> {
|
||||||
|
const img = sharp(snapshotBytes, { failOn: "none" }).rotate();
|
||||||
|
const meta = await img.metadata();
|
||||||
|
const W = meta.width ?? 0;
|
||||||
|
const H = meta.height ?? 0;
|
||||||
|
if (!W || !H) throw new Error("snapshot has no dimensions");
|
||||||
|
const px = (b: NormBox) => ({
|
||||||
|
left: Math.round(b.x1 * W), top: Math.round(b.y1 * H),
|
||||||
|
right: Math.round(b.x2 * W), bottom: Math.round(b.y2 * H),
|
||||||
|
});
|
||||||
|
const v = px(box);
|
||||||
|
const mw = Math.round((v.right - v.left) * CROP_MARGIN);
|
||||||
|
const mh = Math.round((v.bottom - v.top) * CROP_MARGIN);
|
||||||
|
const left = Math.max(0, v.left - mw);
|
||||||
|
const top = Math.max(0, v.top - mh);
|
||||||
|
const right = Math.min(W, v.right + mw);
|
||||||
|
const bottom = Math.min(H, v.bottom + mh);
|
||||||
|
const width = right - left;
|
||||||
|
const height = bottom - top;
|
||||||
|
if (width < 8 || height < 8) throw new Error("vehicle box too small to crop");
|
||||||
|
|
||||||
|
let crop = img.clone().extract({ left, top, width, height });
|
||||||
|
let plateBlurred = false;
|
||||||
|
if (plateBox) {
|
||||||
|
// The plate region, in CROP coordinates, padded a little so the blur eats the edges.
|
||||||
|
const p = px(plateBox);
|
||||||
|
const pad = Math.round(Math.max(p.right - p.left, p.bottom - p.top) * 0.25);
|
||||||
|
const pl = Math.max(0, p.left - pad - left);
|
||||||
|
const pt = Math.max(0, p.top - pad - top);
|
||||||
|
const pr = Math.min(width, p.right + pad - left);
|
||||||
|
const pb = Math.min(height, p.bottom + pad - top);
|
||||||
|
if (pr - pl >= 2 && pb - pt >= 2) {
|
||||||
|
const region = await sharp(await crop.clone().toBuffer())
|
||||||
|
.extract({ left: pl, top: pt, width: pr - pl, height: pb - pt })
|
||||||
|
.blur(Math.max(6, Math.round((pr - pl) / 6)))
|
||||||
|
.toBuffer();
|
||||||
|
crop = sharp(await crop.toBuffer()).composite([{ input: region, left: pl, top: pt }]);
|
||||||
|
plateBlurred = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = await crop
|
||||||
|
.resize({ width: CROP_MAX_EDGE, height: CROP_MAX_EDGE, fit: "inside", withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: 85, mozjpeg: true })
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
return { bytes: out.data, width: out.info.width, height: out.info.height, plateBlurred };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The operator as a keyed hash — stable per booth so the reviewer can count per person,
|
||||||
|
* meaningless anywhere else. */
|
||||||
|
export function operatorRef(boothId: string, username: string): string {
|
||||||
|
return createHash("sha256").update(`${boothId}:${username}`).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
export interface OutboxStatus {
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly boothId: string | null;
|
||||||
|
readonly queued: number;
|
||||||
|
readonly sent: number;
|
||||||
|
readonly failed: number;
|
||||||
|
readonly lastSentAt: string | null;
|
||||||
|
readonly lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReviewOutbox {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #cfg: ReviewUploadConfig | null;
|
||||||
|
readonly #fetch: FetchLike;
|
||||||
|
#timer: NodeJS.Timeout | null = null;
|
||||||
|
#draining = false;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger, cfg: ReviewUploadConfig | null, fetchFn?: FetchLike) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#cfg = cfg;
|
||||||
|
this.#fetch = fetchFn ?? ((input, init) => fetch(input, init));
|
||||||
|
}
|
||||||
|
|
||||||
|
get enabled(): boolean {
|
||||||
|
return this.#cfg != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue one order's package. Fire-and-forget: the caller does NOT await this on the
|
||||||
|
* intake path; every failure is logged, none is thrown. Skipped when there is no
|
||||||
|
* vehicle box (nothing to crop — a frame without a detected vehicle is no training
|
||||||
|
* sample) or when upload is not configured (an unbounded queue nobody drains). */
|
||||||
|
async enqueue(item: ReviewItemInput, read: VehicleRead): Promise<boolean> {
|
||||||
|
if (!this.#cfg) return false;
|
||||||
|
if (!read.box || !read.snapshotId) return false;
|
||||||
|
try {
|
||||||
|
const snap = this.#db.select().from(snapshots).where(eq(snapshots.id, read.snapshotId)).get();
|
||||||
|
if (!snap) {
|
||||||
|
this.#logger.info(`carwash review: snapshot ${read.snapshotId} gone (pruned) — order ${item.orderId} not queued`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const crop = await makeReviewCrop(snap.bytes, read.box, read.plateBox);
|
||||||
|
const id = randomUUID();
|
||||||
|
const payload = {
|
||||||
|
v: 1,
|
||||||
|
booth: this.#cfg.boothId,
|
||||||
|
item: id,
|
||||||
|
order: item.orderId,
|
||||||
|
at: item.createdAt,
|
||||||
|
operator: operatorRef(this.#cfg.boothId, item.createdBy),
|
||||||
|
operatorCategory: { id: item.categoryId, name: item.categoryName },
|
||||||
|
service: item.serviceName,
|
||||||
|
vision: { class: read.bodyType, confidence: read.confidence, categoryId: item.visionCategoryId },
|
||||||
|
downgraded: item.downgraded,
|
||||||
|
image: { width: crop.width, height: crop.height, plateBlurred: crop.plateBlurred },
|
||||||
|
};
|
||||||
|
this.#db
|
||||||
|
.insert(carwashReviewOutbox)
|
||||||
|
.values({ id, orderId: item.orderId, createdAt: new Date().toISOString(), status: "queued", attempts: 0, nextAttemptAt: null, image: crop.bytes, payload })
|
||||||
|
.run();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.warn(`carwash review: could not queue order ${item.orderId}: ${(err as Error).message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (!this.#cfg || this.#timer) return;
|
||||||
|
const tick = () => {
|
||||||
|
void this.drain().catch((err) => this.#logger.warn(`carwash review: drain failed: ${(err as Error).message}`));
|
||||||
|
};
|
||||||
|
this.#timer = setInterval(tick, this.#cfg.intervalSec * 1000);
|
||||||
|
this.#timer.unref?.();
|
||||||
|
setTimeout(tick, 5_000).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.#timer) clearInterval(this.#timer);
|
||||||
|
this.#timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send what is due, oldest first. Returns the tally; never throws for a single item. */
|
||||||
|
async drain(limit = 20): Promise<{ sent: number; failed: number; deferred: number }> {
|
||||||
|
const tally = { sent: 0, failed: 0, deferred: 0 };
|
||||||
|
if (!this.#cfg || this.#draining) return tally;
|
||||||
|
this.#draining = true;
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const due = this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashReviewOutbox)
|
||||||
|
.where(and(eq(carwashReviewOutbox.status, "queued"), or(isNull(carwashReviewOutbox.nextAttemptAt), lte(carwashReviewOutbox.nextAttemptAt, now))))
|
||||||
|
.orderBy(asc(carwashReviewOutbox.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.all();
|
||||||
|
for (const row of due) {
|
||||||
|
const outcome = await this.#send(row);
|
||||||
|
tally[outcome] += 1;
|
||||||
|
}
|
||||||
|
if (tally.sent || tally.failed) this.#logger.info(`carwash review: sent ${tally.sent}, failed ${tally.failed}, deferred ${tally.deferred}`);
|
||||||
|
} finally {
|
||||||
|
this.#draining = false;
|
||||||
|
}
|
||||||
|
return tally;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #send(row: typeof carwashReviewOutbox.$inferSelect): Promise<"sent" | "failed" | "deferred"> {
|
||||||
|
const cfg = this.#cfg!;
|
||||||
|
const ageMs = Date.now() - Date.parse(row.createdAt);
|
||||||
|
if (ageMs > EXPIRE_DAYS * 24 * 60 * 60 * 1000) return this.#fail(row, `expired after ${EXPIRE_DAYS} days`);
|
||||||
|
// A wash voided before delivery is not a sample (and not a decision to review).
|
||||||
|
const order = this.#db.select({ status: carwashOrders.status }).from(carwashOrders).where(eq(carwashOrders.id, row.orderId)).get();
|
||||||
|
if (order?.status === "void") return this.#fail(row, "order voided");
|
||||||
|
if (!row.image) return this.#fail(row, "image missing");
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("meta", JSON.stringify(row.payload));
|
||||||
|
form.set("image", new Blob([new Uint8Array(row.image)], { type: "image/jpeg" }), `${row.id}.jpg`);
|
||||||
|
const ac = new AbortController();
|
||||||
|
const t = setTimeout(() => ac.abort(), UPLOAD_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await this.#fetch(cfg.url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${cfg.token}`, "x-booth-id": cfg.boothId },
|
||||||
|
body: form,
|
||||||
|
signal: ac.signal,
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
this.#db
|
||||||
|
.update(carwashReviewOutbox)
|
||||||
|
.set({ status: "sent", sentAt: new Date().toISOString(), image: null, lastError: null, attempts: row.attempts + 1 })
|
||||||
|
.where(eq(carwashReviewOutbox.id, row.id))
|
||||||
|
.run();
|
||||||
|
return "sent";
|
||||||
|
}
|
||||||
|
// The collector refused the package itself → no retry will help.
|
||||||
|
if ([400, 404, 413, 415, 422].includes(res.status)) return this.#fail(row, `rejected: HTTP ${res.status}`);
|
||||||
|
// Everything else (auth not yet fixed, throttled, collector down) → try again later.
|
||||||
|
return this.#defer(row, `HTTP ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
return this.#defer(row, (err as Error).name === "AbortError" ? "timeout" : (err as Error).message);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#fail(row: typeof carwashReviewOutbox.$inferSelect, why: string): "failed" {
|
||||||
|
this.#db
|
||||||
|
.update(carwashReviewOutbox)
|
||||||
|
.set({ status: "failed", lastError: why, image: null, attempts: row.attempts + 1 })
|
||||||
|
.where(eq(carwashReviewOutbox.id, row.id))
|
||||||
|
.run();
|
||||||
|
this.#logger.warn(`carwash review: item ${row.id} (order ${row.orderId}) abandoned — ${why}`);
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
#defer(row: typeof carwashReviewOutbox.$inferSelect, why: string): "deferred" {
|
||||||
|
const attempts = row.attempts + 1;
|
||||||
|
const wait = Math.min(BACKOFF_BASE_MS * 2 ** Math.min(attempts, 20), BACKOFF_CAP_MS);
|
||||||
|
this.#db
|
||||||
|
.update(carwashReviewOutbox)
|
||||||
|
.set({ attempts, lastError: why, nextAttemptAt: new Date(Date.now() + wait).toISOString() })
|
||||||
|
.where(eq(carwashReviewOutbox.id, row.id))
|
||||||
|
.run();
|
||||||
|
return "deferred";
|
||||||
|
}
|
||||||
|
|
||||||
|
status(): OutboxStatus {
|
||||||
|
const count = (s: "queued" | "sent" | "failed") =>
|
||||||
|
this.#db.select({ n: sql<number>`count(*)` }).from(carwashReviewOutbox).where(eq(carwashReviewOutbox.status, s)).get()?.n ?? 0;
|
||||||
|
const lastSent = this.#db.select({ at: sql<string | null>`max(${carwashReviewOutbox.sentAt})` }).from(carwashReviewOutbox).get()?.at ?? null;
|
||||||
|
const lastErr = this.#db
|
||||||
|
.select({ e: carwashReviewOutbox.lastError })
|
||||||
|
.from(carwashReviewOutbox)
|
||||||
|
.where(sql`${carwashReviewOutbox.lastError} is not null`)
|
||||||
|
.orderBy(sql`coalesce(${carwashReviewOutbox.sentAt}, ${carwashReviewOutbox.nextAttemptAt}, ${carwashReviewOutbox.createdAt}) desc`)
|
||||||
|
.limit(1)
|
||||||
|
.get()?.e ?? null;
|
||||||
|
return {
|
||||||
|
enabled: this.enabled,
|
||||||
|
boothId: this.#cfg?.boothId ?? null,
|
||||||
|
queued: count("queued"),
|
||||||
|
sent: count("sent"),
|
||||||
|
failed: count("failed"),
|
||||||
|
lastSentAt: lastSent,
|
||||||
|
lastError: lastErr,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { requireAnyPermission, requirePermission } from "../../auth.js";
|
|||||||
import { requireModule } from "../../modules.js";
|
import { requireModule } from "../../modules.js";
|
||||||
import { NoShiftOpenError } from "../../shift-service.js";
|
import { NoShiftOpenError } from "../../shift-service.js";
|
||||||
import type { ServerModuleDeps } from "../index.js";
|
import type { ServerModuleDeps } from "../index.js";
|
||||||
|
import type { ReviewOutbox } from "./review-outbox.js";
|
||||||
import { CarwashError, CarwashService, isPayAt, type SettingsBody } from "./service.js";
|
import { CarwashError, CarwashService, isPayAt, type SettingsBody } from "./service.js";
|
||||||
|
|
||||||
// HTTP surface of the Car Wash module. Every route is behind the venue-module gate
|
// HTTP surface of the Car Wash module. Every route is behind the venue-module gate
|
||||||
@@ -27,7 +28,7 @@ function sendError(reply: FastifyReply, err: unknown): FastifyReply {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService): Promise<void> {
|
export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService, outbox?: ReviewOutbox): Promise<void> {
|
||||||
const moduleOn = requireModule(deps.db, "carwash");
|
const moduleOn = requireModule(deps.db, "carwash");
|
||||||
// The price list is the desk's working data as much as Setup's: the wash operator
|
// The price list is the desk's working data as much as Setup's: the wash operator
|
||||||
// reads it under the module's own permission (the Wash operator job holds no site:*).
|
// reads it under the module's own permission (the Wash operator job holds no site:*).
|
||||||
@@ -38,6 +39,11 @@ export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps
|
|||||||
const update = [moduleOn, requirePermission("carwash:update")];
|
const update = [moduleOn, requirePermission("carwash:update")];
|
||||||
|
|
||||||
app.get("/api/carwash/settings", { preHandler: settingsRead }, async () => service.settings());
|
app.get("/api/carwash/settings", { preHandler: settingsRead }, async () => service.settings());
|
||||||
|
// The review outbox's health (Setup → Car wash): how many decisions wait for the
|
||||||
|
// reviewer, how many went, the last error. Site admin's read.
|
||||||
|
app.get("/api/carwash/review/status", { preHandler: settingsRead }, async () =>
|
||||||
|
outbox?.status() ?? { enabled: false, boothId: null, queued: 0, sent: 0, failed: 0, lastSentAt: null, lastError: null },
|
||||||
|
);
|
||||||
|
|
||||||
app.put<{ Body: SettingsBody }>("/api/carwash/settings", { preHandler: settingsWrite }, async (req, reply) => {
|
app.put<{ Body: SettingsBody }>("/api/carwash/settings", { preHandler: settingsWrite }, async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from "@parking/shared";
|
} from "@parking/shared";
|
||||||
import type { EventLog } from "../../event-log.js";
|
import type { EventLog } from "../../event-log.js";
|
||||||
import { vehicleForIdentity } from "../../plate-lookup.js";
|
import { vehicleForIdentity } from "../../plate-lookup.js";
|
||||||
|
import type { ReviewOutbox } from "./review-outbox.js";
|
||||||
import { effectiveModulesFor } from "../../modules.js";
|
import { effectiveModulesFor } from "../../modules.js";
|
||||||
import type { ChargeProvider, PayStation } from "../../pay-station.js";
|
import type { ChargeProvider, PayStation } from "../../pay-station.js";
|
||||||
import type { ShiftService } from "../../shift-service.js";
|
import type { ShiftService } from "../../shift-service.js";
|
||||||
@@ -116,13 +117,15 @@ export class CarwashService {
|
|||||||
readonly #pay: PayStation;
|
readonly #pay: PayStation;
|
||||||
readonly #shift: ShiftService;
|
readonly #shift: ShiftService;
|
||||||
readonly #logger: FastifyBaseLogger;
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #outbox: ReviewOutbox | null;
|
||||||
|
|
||||||
constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger) {
|
constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger, outbox: ReviewOutbox | null = null) {
|
||||||
this.#db = deps.db;
|
this.#db = deps.db;
|
||||||
this.#log = deps.eventLog;
|
this.#log = deps.eventLog;
|
||||||
this.#pay = deps.payStation;
|
this.#pay = deps.payStation;
|
||||||
this.#shift = deps.shiftService;
|
this.#shift = deps.shiftService;
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
|
this.#outbox = outbox;
|
||||||
}
|
}
|
||||||
|
|
||||||
#enabled(): boolean {
|
#enabled(): boolean {
|
||||||
@@ -523,6 +526,22 @@ export class CarwashService {
|
|||||||
downgradeEventId,
|
downgradeEventId,
|
||||||
};
|
};
|
||||||
this.#db.insert(carwashOrders).values(row).run();
|
this.#db.insert(carwashOrders).values(row).run();
|
||||||
|
// Hand the decision to the remote reviewer (crop + choice), off the intake path.
|
||||||
|
if (vision && this.#outbox?.enabled) {
|
||||||
|
void this.#outbox.enqueue(
|
||||||
|
{
|
||||||
|
orderId: row.id,
|
||||||
|
createdAt: now,
|
||||||
|
createdBy: input.actor,
|
||||||
|
categoryId: category.id,
|
||||||
|
categoryName: category.name,
|
||||||
|
serviceName: service.name,
|
||||||
|
visionCategoryId: visionCategory?.id ?? null,
|
||||||
|
downgraded: downgradeEventId != null,
|
||||||
|
},
|
||||||
|
vision,
|
||||||
|
);
|
||||||
|
}
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "carwash_order",
|
type: "carwash_order",
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
|
import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
|
||||||
import { isVehicleClass, type VehicleRead } from "@parking/shared";
|
import { isNormBox, isVehicleClass, type VehicleRead } from "@parking/shared";
|
||||||
|
|
||||||
// READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it
|
// READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it
|
||||||
// lives in the unsigned, prunable `device_events` (kind="read") stream written by the
|
// lives in the unsigned, prunable `device_events` (kind="read") stream written by the
|
||||||
@@ -27,6 +27,8 @@ interface ReadDetail {
|
|||||||
bodyType?: string;
|
bodyType?: string;
|
||||||
bodyConfidence?: number;
|
bodyConfidence?: number;
|
||||||
snapshotId?: string;
|
snapshotId?: string;
|
||||||
|
vehicleBox?: unknown;
|
||||||
|
plateBox?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The advisory VEHICLE read (body type) for a session — the same stream and the same
|
/** The advisory VEHICLE read (body type) for a session — the same stream and the same
|
||||||
@@ -43,7 +45,13 @@ export function vehicleForIdentity(db: Db, identity: string): VehicleRead | null
|
|||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const d = (r.detail ?? {}) as ReadDetail;
|
const d = (r.detail ?? {}) as ReadDetail;
|
||||||
if (d.identity !== identity || !isVehicleClass(d.bodyType) || typeof d.bodyConfidence !== "number") continue;
|
if (d.identity !== identity || !isVehicleClass(d.bodyType) || typeof d.bodyConfidence !== "number") continue;
|
||||||
const v: VehicleRead = { bodyType: d.bodyType, confidence: d.bodyConfidence, snapshotId: d.snapshotId ?? null };
|
const v: VehicleRead = {
|
||||||
|
bodyType: d.bodyType,
|
||||||
|
confidence: d.bodyConfidence,
|
||||||
|
snapshotId: d.snapshotId ?? null,
|
||||||
|
box: isNormBox(d.vehicleBox) ? d.vehicleBox : null,
|
||||||
|
plateBox: isNormBox(d.plateBox) ? d.plateBox : null,
|
||||||
|
};
|
||||||
if (d.direction === "entry") return v;
|
if (d.direction === "entry") return v;
|
||||||
if (!fallback) fallback = v;
|
if (!fallback) fallback = v;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,13 +168,26 @@ async function recognizePlate(
|
|||||||
try {
|
try {
|
||||||
const result = await vision.analyze(shot.bytes, shot.contentType);
|
const result = await vision.analyze(shot.bytes, shot.contentType);
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
// Boxes are kept as FRACTIONS of the analysed frame (the stored snapshot is a
|
||||||
|
// downscaled copy — see reencodeForStorage), so the wash's review crop can cut the
|
||||||
|
// vehicle out of whatever copy survives and blur the plate inside it.
|
||||||
|
const frame = await frameSize(shot.bytes);
|
||||||
|
const norm = (b: { x1: number; y1: number; x2: number; y2: number } | null | undefined) =>
|
||||||
|
b && frame
|
||||||
|
? {
|
||||||
|
x1: clamp01(b.x1 / frame.w), y1: clamp01(b.y1 / frame.h),
|
||||||
|
x2: clamp01(b.x2 / frame.w), y2: clamp01(b.y2 / frame.h),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
// The vehicle's body type (advisory; the wash desk's category suggestion — see
|
// The vehicle's body type (advisory; the wash desk's category suggestion — see
|
||||||
// venue-modules.md §Vehicle category). Rides the plate's read row when there is one,
|
// venue-modules.md §Vehicle category). Rides the plate's read row when there is one,
|
||||||
// else a row of its own: a car with an unreadable plate is still a car of some class.
|
// else a row of its own: a car with an unreadable plate is still a car of some class.
|
||||||
|
const vehicleBox = norm(result.vehicle?.bbox);
|
||||||
const vehicle = result.vehicle
|
const vehicle = result.vehicle
|
||||||
? { bodyType: result.vehicle.bodyType, bodyConfidence: result.vehicle.confidence }
|
? { bodyType: result.vehicle.bodyType, bodyConfidence: result.vehicle.confidence, ...(vehicleBox ? { vehicleBox } : {}) }
|
||||||
: {};
|
: {};
|
||||||
const plate = !result.plate || result.lowConfidence ? "" : result.plate.text.trim().toUpperCase();
|
const plate = !result.plate || result.lowConfidence ? "" : result.plate.text.trim().toUpperCase();
|
||||||
|
const plateBox = plate ? norm(result.plate?.bbox) : null;
|
||||||
if (!plate && !result.vehicle) return; // nothing trustworthy to record
|
if (!plate && !result.vehicle) return; // nothing trustworthy to record
|
||||||
db.insert(deviceEventsTable)
|
db.insert(deviceEventsTable)
|
||||||
.values({
|
.values({
|
||||||
@@ -187,7 +200,7 @@ async function recognizePlate(
|
|||||||
identity,
|
identity,
|
||||||
direction,
|
direction,
|
||||||
...(plate
|
...(plate
|
||||||
? { plate, confidence: result.plate!.confidence, region: result.plate!.region ?? null }
|
? { plate, confidence: result.plate!.confidence, region: result.plate!.region ?? null, ...(plateBox ? { plateBox } : {}) }
|
||||||
: {}),
|
: {}),
|
||||||
...vehicle,
|
...vehicle,
|
||||||
modelVersion: result.modelVersion,
|
modelVersion: result.modelVersion,
|
||||||
@@ -215,6 +228,20 @@ async function recognizePlate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clamp01(v: number): number {
|
||||||
|
return Math.max(0, Math.min(1, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pixel size of the analysed frame (JPEG header only — cheap). Null when unreadable. */
|
||||||
|
async function frameSize(bytes: Buffer): Promise<{ w: number; h: number } | null> {
|
||||||
|
try {
|
||||||
|
const m = await sharp(bytes, { failOn: "none" }).metadata();
|
||||||
|
return m.width && m.height ? { w: m.width, h: m.height } : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** How far back a recognized entry plate is compared against other OPEN sessions'
|
/** How far back a recognized entry plate is compared against other OPEN sessions'
|
||||||
* entry plates. Short on purpose: the duplicate-ticket scenario is the same car
|
* entry plates. Short on purpose: the duplicate-ticket scenario is the same car
|
||||||
* re-pressing within minutes; a long window would flag legit re-visits. */
|
* re-pressing within minutes; a long window would flag legit re-visits. */
|
||||||
|
|||||||
@@ -48,13 +48,15 @@ export interface VisionPlate {
|
|||||||
export interface VisionVehicle {
|
export interface VisionVehicle {
|
||||||
readonly bodyType: VehicleClass;
|
readonly bodyType: VehicleClass;
|
||||||
readonly confidence: number;
|
readonly confidence: number;
|
||||||
|
/** The vehicle's box in frame pixels, when the stage found one. */
|
||||||
|
readonly bbox?: PlateBBox | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The raw /analyze response shape (the Python contract). */
|
/** The raw /analyze response shape (the Python contract). */
|
||||||
interface AnalyzeResponse {
|
interface AnalyzeResponse {
|
||||||
readonly plate: VisionPlate | null;
|
readonly plate: VisionPlate | null;
|
||||||
readonly plates: VisionPlate[];
|
readonly plates: VisionPlate[];
|
||||||
readonly vehicle: { body_type?: string | null; confidence?: number | null } | null;
|
readonly vehicle: { body_type?: string | null; confidence?: number | null; bbox?: PlateBBox | null } | null;
|
||||||
readonly low_confidence: boolean;
|
readonly low_confidence: boolean;
|
||||||
readonly model_version: string;
|
readonly model_version: string;
|
||||||
readonly took_ms: number;
|
readonly took_ms: number;
|
||||||
@@ -138,7 +140,7 @@ export class VisionClient {
|
|||||||
const v = res.vehicle;
|
const v = res.vehicle;
|
||||||
const vehicle: VisionVehicle | null =
|
const vehicle: VisionVehicle | null =
|
||||||
v && isVehicleClass(v.body_type) && typeof v.confidence === "number"
|
v && isVehicleClass(v.body_type) && typeof v.confidence === "number"
|
||||||
? { bodyType: v.body_type, confidence: Math.max(0, Math.min(1, v.confidence)) }
|
? { bodyType: v.body_type, confidence: Math.max(0, Math.min(1, v.confidence)), bbox: v.bbox ?? null }
|
||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
plate: best,
|
plate: best,
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ class VehicleResult(BaseModel):
|
|||||||
body_type: str | None = None
|
body_type: str | None = None
|
||||||
# Confidence of `body_type` (0–1). Node compares it to the site's threshold.
|
# Confidence of `body_type` (0–1). Node compares it to the site's threshold.
|
||||||
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||||
|
# The vehicle's box in frame pixels — the crop a reviewer sees / a classifier eats.
|
||||||
|
bbox: BBox | None = None
|
||||||
make: str | None = None
|
make: str | None = None
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -235,7 +235,11 @@ class YoloxVehicleDetector:
|
|||||||
best = pick_vehicle(found, plate)
|
best = pick_vehicle(found, plate)
|
||||||
if best is None:
|
if best is None:
|
||||||
return None
|
return None
|
||||||
return VehicleResult(body_type=best.body_type, confidence=round(best.confidence, 4))
|
h, w = frame.shape[:2]
|
||||||
|
box = BBox(
|
||||||
|
x1=max(0, int(best.x1)), y1=max(0, int(best.y1)), x2=min(w, int(best.x2)), y2=min(h, int(best.y2))
|
||||||
|
)
|
||||||
|
return VehicleResult(body_type=best.body_type, confidence=round(best.confidence, 4), bbox=box)
|
||||||
|
|
||||||
|
|
||||||
def time_detect(
|
def time_detect(
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ export const en: Catalog = {
|
|||||||
visionSaw: "Camera saw",
|
visionSaw: "Camera saw",
|
||||||
visionUnmapped: "not mapped to a category",
|
visionUnmapped: "not mapped to a category",
|
||||||
visionClasses: "Camera classes",
|
visionClasses: "Camera classes",
|
||||||
|
reviewTitle: "Remote review",
|
||||||
|
reviewOff: "off — no collector configured for this booth",
|
||||||
|
reviewCounts: "{{queued}} waiting · {{sent}} delivered · {{failed}} abandoned",
|
||||||
|
reviewHint: "Each wash order sends the vehicle crop (plate blurred) and the chosen category to a trusted reviewer over the private network. One-way; nothing that names this site leaves.",
|
||||||
visionClassesHint: "The camera's fixed vocabulary (set in code, not here). Tick the classes this category covers.",
|
visionClassesHint: "The camera's fixed vocabulary (set in code, not here). Tick the classes this category covers.",
|
||||||
visionThreshold: "Camera confidence to flag a downgrade",
|
visionThreshold: "Camera confidence to flag a downgrade",
|
||||||
visionThresholdHint: "When the camera is at least this sure and the operator picks a cheaper category than the one its class maps to, the order is flagged for review. It is never blocked.",
|
visionThresholdHint: "When the camera is at least this sure and the operator picks a cheaper category than the one its class maps to, the order is flagged for review. It is never blocked.",
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ export const sq = {
|
|||||||
visionSaw: "Kamera pa",
|
visionSaw: "Kamera pa",
|
||||||
visionUnmapped: "pa kategori të lidhur",
|
visionUnmapped: "pa kategori të lidhur",
|
||||||
visionClasses: "Klasat e kamerës",
|
visionClasses: "Klasat e kamerës",
|
||||||
|
reviewTitle: "Shqyrtim në distancë",
|
||||||
|
reviewOff: "joaktiv — asnjë mbledhës i konfiguruar për këtë kabinë",
|
||||||
|
reviewCounts: "{{queued}} në pritje · {{sent}} të dërguara · {{failed}} të braktisura",
|
||||||
|
reviewHint: "Çdo porosi lavazhi dërgon prerjen e mjetit (targa e turbulluar) dhe kategorinë e zgjedhur te një shqyrtues i besuar përmes rrjetit privat. Njëkahësh; asgjë që emërton këtë vend nuk del.",
|
||||||
visionClassesHint: "Fjalori i fiksuar i kamerës (vendoset në kod, jo këtu). Shëno klasat që mbulon kjo kategori.",
|
visionClassesHint: "Fjalori i fiksuar i kamerës (vendoset në kod, jo këtu). Shëno klasat që mbulon kjo kategori.",
|
||||||
visionThreshold: "Siguria e kamerës për të shënuar një ulje kategorie",
|
visionThreshold: "Siguria e kamerës për të shënuar një ulje kategorie",
|
||||||
visionThresholdHint: "Kur kamera është të paktën kaq e sigurt dhe operatori zgjedh një kategori më të lirë se ajo ku lidhet klasa, porosia shënohet për shqyrtim. Nuk bllokohet kurrë.",
|
visionThresholdHint: "Kur kamera është të paktën kaq e sigurt dhe operatori zgjedh një kategori më të lirë se ajo ku lidhet klasa, porosia shënohet për shqyrtim. Nuk bllokohet kurrë.",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { CARWASH_PAY_AT, CARWASH_PROGRAM_ID, CARWASH_VALIDATION_MODES, VEHICLE_CLASSES, type CarWashPayAt, type VehicleClass } from "@parking/shared";
|
import { CARWASH_PAY_AT, CARWASH_PROGRAM_ID, CARWASH_VALIDATION_MODES, VEHICLE_CLASSES, type CarWashPayAt, type VehicleClass } from "@parking/shared";
|
||||||
import { fetchValidationPrograms, type ValidationProgramView } from "../../api.js";
|
import { fetchValidationPrograms, type ValidationProgramView } from "../../api.js";
|
||||||
import { StationForm, defaultProgram } from "../../ValidationSetup.js";
|
import { StationForm, defaultProgram } from "../../ValidationSetup.js";
|
||||||
import { fetchCarwashSettings, saveCarwashSettings, type CarwashSettingsView } from "./api.js";
|
import { fetchCarwashReviewStatus, fetchCarwashSettings, saveCarwashSettings, type CarwashReviewStatus, type CarwashSettingsView } from "./api.js";
|
||||||
|
|
||||||
// Setup → Car wash: the master data (vehicle categories, services, the category ×
|
// Setup → Car wash: the master data (vehicle categories, services, the category ×
|
||||||
// service price matrix) and the parking SPONSORSHIP a wash grants — the latter is a
|
// service price matrix) and the parking SPONSORSHIP a wash grants — the latter is a
|
||||||
@@ -113,6 +113,7 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
|||||||
const [threshold, setThreshold] = useState("80");
|
const [threshold, setThreshold] = useState("80");
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
const [program, setProgram] = useState<ValidationProgramView | null>(null);
|
const [program, setProgram] = useState<ValidationProgramView | null>(null);
|
||||||
|
const [review, setReview] = useState<CarwashReviewStatus | null>(null);
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
fetchCarwashSettings()
|
fetchCarwashSettings()
|
||||||
@@ -127,6 +128,7 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
|||||||
setThreshold(String(Math.round(s.visionThreshold * 100)));
|
setThreshold(String(Math.round(s.visionThreshold * 100)));
|
||||||
})
|
})
|
||||||
.catch((e) => setMsg((e as Error).message));
|
.catch((e) => setMsg((e as Error).message));
|
||||||
|
fetchCarwashReviewStatus().then(setReview).catch(() => {});
|
||||||
fetchValidationPrograms()
|
fetchValidationPrograms()
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
const existing = r.programs.find((p) => p.id === CARWASH_PROGRAM_ID);
|
const existing = r.programs.find((p) => p.id === CARWASH_PROGRAM_ID);
|
||||||
@@ -265,6 +267,18 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
<span className="hint">{t("wash.visionThresholdHint")}</span>
|
<span className="hint">{t("wash.visionThresholdHint")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{review && (
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("wash.reviewTitle")}</span>
|
||||||
|
<span className="text-[0.75rem] tabular-nums">
|
||||||
|
{review.enabled
|
||||||
|
? t("wash.reviewCounts", { queued: review.queued, sent: review.sent, failed: review.failed })
|
||||||
|
: t("wash.reviewOff")}
|
||||||
|
{review.enabled && review.lastError && <span className="ml-2 text-term-amber">{review.lastError}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="hint">{t("wash.reviewHint")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -31,6 +31,20 @@ export interface CarwashSettingsBody {
|
|||||||
visionThreshold?: number;
|
visionThreshold?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The review outbox's health (Setup → Car wash). */
|
||||||
|
export interface CarwashReviewStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
boothId: string | null;
|
||||||
|
queued: number;
|
||||||
|
sent: number;
|
||||||
|
failed: number;
|
||||||
|
lastSentAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
}
|
||||||
|
export function fetchCarwashReviewStatus(): Promise<CarwashReviewStatus> {
|
||||||
|
return apiFetch("/api/carwash/review/status");
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchCarwashSettings(): Promise<CarwashSettingsView> {
|
export function fetchCarwashSettings(): Promise<CarwashSettingsView> {
|
||||||
return apiFetch("/api/carwash/settings");
|
return apiFetch("/api/carwash/settings");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ services:
|
|||||||
# off (the in-UI target + retention do nothing without it). Per-booth + unique; escrow it
|
# off (the in-UI target + retention do nothing without it). Per-booth + unique; escrow it
|
||||||
# offsite. See apps/server/.env.example + wiki/concepts/backup-recovery.md.
|
# offsite. See apps/server/.env.example + wiki/concepts/backup-recovery.md.
|
||||||
BACKUP_KEY: ${BACKUP_KEY:-}
|
BACKUP_KEY: ${BACKUP_KEY:-}
|
||||||
|
# Car Wash review outbox: collector URL + per-booth token + pseudonymous booth id, all
|
||||||
|
# three or off. See apps/server/.env.example + wiki/concepts/vision-review-outbox.md.
|
||||||
|
CARWASH_REVIEW_URL: ${CARWASH_REVIEW_URL:-}
|
||||||
|
CARWASH_REVIEW_TOKEN: ${CARWASH_REVIEW_TOKEN:-}
|
||||||
|
CARWASH_REVIEW_BOOTH_ID: ${CARWASH_REVIEW_BOOTH_ID:-}
|
||||||
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT,
|
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT,
|
||||||
# so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators
|
# so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators
|
||||||
# CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook".
|
# CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook".
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Car Wash review outbox (wiki/concepts/vision-review-outbox.md): plate-blurred vehicle
|
||||||
|
-- crops + the operator's category choice, queued for a trusted remote reviewer and drained
|
||||||
|
-- one-way over the private overlay. The image is cleared once delivered.
|
||||||
|
CREATE TABLE `carwash_review_outbox` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`order_id` text NOT NULL,
|
||||||
|
`created_at` text NOT NULL,
|
||||||
|
`status` text DEFAULT 'queued' NOT NULL,
|
||||||
|
`attempts` integer DEFAULT 0 NOT NULL,
|
||||||
|
`next_attempt_at` text,
|
||||||
|
`last_error` text,
|
||||||
|
`sent_at` text,
|
||||||
|
`image` blob,
|
||||||
|
`payload` text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `carwash_review_outbox_status_idx` ON `carwash_review_outbox` (`status`,`next_attempt_at`);
|
||||||
@@ -218,6 +218,13 @@
|
|||||||
"when": 1788700000000,
|
"when": 1788700000000,
|
||||||
"tag": "0030_carwash_vision",
|
"tag": "0030_carwash_vision",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 31,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788710000000,
|
||||||
|
"tag": "0031_carwash_review_outbox",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -746,6 +746,26 @@ export const carwashConfig = sqliteTable("carwash_config", {
|
|||||||
updatedBy: text("updated_by"),
|
updatedBy: text("updated_by"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Car Wash REVIEW OUTBOX (wiki/concepts/vision-review-outbox.md): the operator's category
|
||||||
|
* choice is a hypothesis, not truth — each wash order with a vehicle read queues a
|
||||||
|
* plate-blurred vehicle CROP + the choice for a trusted remote reviewer, drained one-way
|
||||||
|
* over the private overlay when it is up. Never blocks the wash; nothing that names the
|
||||||
|
* site leaves the booth. The image is dropped once delivered. */
|
||||||
|
export const carwashReviewOutbox = sqliteTable("carwash_review_outbox", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
orderId: text("order_id").notNull(),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
status: text("status", { enum: ["queued", "sent", "failed"] }).notNull().default("queued"),
|
||||||
|
attempts: integer("attempts").notNull().default(0),
|
||||||
|
nextAttemptAt: text("next_attempt_at"),
|
||||||
|
lastError: text("last_error"),
|
||||||
|
sentAt: text("sent_at"),
|
||||||
|
/** The JPEG crop (plate blurred). Null once sent. */
|
||||||
|
image: blob("image").$type<Buffer>(),
|
||||||
|
/** What the collector receives beside the image (no site name, no plate, no operator name). */
|
||||||
|
payload: text("payload", { mode: "json" }).$type<Record<string, unknown>>().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
export type CarwashCategoryRow = typeof carwashCategories.$inferSelect;
|
export type CarwashCategoryRow = typeof carwashCategories.$inferSelect;
|
||||||
export type CarwashServiceRow = typeof carwashServices.$inferSelect;
|
export type CarwashServiceRow = typeof carwashServices.$inferSelect;
|
||||||
export type CarwashPriceRow = typeof carwashPrices.$inferSelect;
|
export type CarwashPriceRow = typeof carwashPrices.$inferSelect;
|
||||||
|
|||||||
@@ -2050,6 +2050,23 @@ export interface VehicleRead {
|
|||||||
readonly bodyType: VehicleClass;
|
readonly bodyType: VehicleClass;
|
||||||
readonly confidence: number;
|
readonly confidence: number;
|
||||||
readonly snapshotId: string | null;
|
readonly snapshotId: string | null;
|
||||||
|
/** The vehicle's box and the plate's box as FRACTIONS of the frame (0–1), so they fit
|
||||||
|
* any resized copy of the snapshot. Absent on reads made before boxes were kept. */
|
||||||
|
readonly box?: NormBox | null;
|
||||||
|
readonly plateBox?: NormBox | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A box as fractions of the frame it was found in (x1,y1 top-left; 0–1). */
|
||||||
|
export interface NormBox {
|
||||||
|
readonly x1: number;
|
||||||
|
readonly y1: number;
|
||||||
|
readonly x2: number;
|
||||||
|
readonly y2: number;
|
||||||
|
}
|
||||||
|
export function isNormBox(v: unknown): v is NormBox {
|
||||||
|
if (!v || typeof v !== "object") return false;
|
||||||
|
const b = v as Record<string, unknown>;
|
||||||
|
return ["x1", "y1", "x2", "y2"].every((k) => typeof b[k] === "number" && (b[k] as number) >= 0 && (b[k] as number) <= 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CarwashSettingsView {
|
export interface CarwashSettingsView {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
title: Vision review outbox — harvesting the operator's category choice for a trusted reviewer
|
||||||
|
type: concept
|
||||||
|
status: booth side built 2026-09-06; collector pending
|
||||||
|
related: [venue-modules, opencv-anpr-service, threat-model, append-only-event-chain, network-isolation]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vision review outbox
|
||||||
|
|
||||||
|
**The idea (user, 2026-09-06).** The Car Wash desk asks the operator for the vehicle's category,
|
||||||
|
and the entry camera now proposes one ([[venue-modules]] §Vehicle category from vision). The
|
||||||
|
operator's choice is what we would love to train the body-type classifier on — but the
|
||||||
|
operator **cannot be fully trusted** (mistake or intent; the [[threat-model]]). So the booth
|
||||||
|
hands each decision to a **trusted party** who reviews the picture and the label remotely,
|
||||||
|
and *that* verdict is the training label — and, per operator, the honest-mistake / fraud rate.
|
||||||
|
The booths sit on a private zero-trust overlay (**Netbird**), so the hand-off can go to a very
|
||||||
|
locked-down collector without exposing anything to the open internet.
|
||||||
|
|
||||||
|
## Rules (all enforced in `apps/server/src/modules/carwash/review-outbox.ts`)
|
||||||
|
|
||||||
|
1. **Offline-first, never on the intake path.** Creating a wash order *queues* a package (fire
|
||||||
|
and forget — a failure is a log line); a background loop drains the queue when the overlay
|
||||||
|
is up. The wash never waits on the network.
|
||||||
|
2. **One-way.** The booth POSTs; nothing ever comes back into the booth's decisions. The signed
|
||||||
|
ledger ([[append-only-event-chain]]) stays the only record of what happened at the wash.
|
||||||
|
Reviewer verdicts stay central and reach the owner as a report per site.
|
||||||
|
3. **Nothing that names the site leaves the booth.**
|
||||||
|
- Only the vehicle **crop** (the detector's box + 8 % margin, ≤ 640 px) — no walls, no camera
|
||||||
|
OSD (date / camera name burned into the frame), no bystanders.
|
||||||
|
- The **plate is blurred inside the crop** on the booth, from the plate detector's own box.
|
||||||
|
- The booth is a **pseudonymous id** set at deploy (`CARWASH_REVIEW_BOOTH_ID`); the operator
|
||||||
|
is a **keyed hash** (`sha256(boothId:username)[:16]`). The mapping back to places and
|
||||||
|
people is the reviewer's, held off the collector. The dataset export drops even those.
|
||||||
|
- Boxes are stored as **fractions of the frame** on the vision read, so the crop is cut from
|
||||||
|
the stored (downscaled) snapshot copy.
|
||||||
|
4. **The network is not the auth.** A per-booth bearer token on top of the overlay; the booth
|
||||||
|
can do nothing at the collector but this one POST. Payloads are small (a crop ≈ 50–80 kB).
|
||||||
|
5. **Data minimisation.** Queued only when there is a vehicle box (no box = no sample); the
|
||||||
|
image is dropped from the row once delivered; a voided order is abandoned unsent; anything
|
||||||
|
older than 14 days is abandoned ("expired") rather than resurfacing a fortnight in a burst.
|
||||||
|
|
||||||
|
## The package
|
||||||
|
|
||||||
|
`multipart/form-data`: `meta` (JSON) + `image` (JPEG). Meta = `{ v, booth, item, order, at,
|
||||||
|
operator (hash), operatorCategory {id,name}, service, vision {class, confidence, categoryId},
|
||||||
|
downgraded, image {width, height, plateBlurred} }`. Headers: `Authorization: Bearer <token>`,
|
||||||
|
`X-Booth-Id`.
|
||||||
|
|
||||||
|
## Draining
|
||||||
|
|
||||||
|
Every `CARWASH_REVIEW_INTERVAL_SEC` (60): due items oldest-first, 20 per pass. `2xx` → sent
|
||||||
|
(image cleared). `400/404/413/415/422` → abandoned (the collector refused the package itself).
|
||||||
|
Anything else (auth not yet fixed, 429, 5xx, timeout, no route) → retry with backoff
|
||||||
|
`1 min · 2^attempts`, capped at 6 h. `GET /api/carwash/review/status` (site:read) and a line in
|
||||||
|
Setup → Car wash show queued / delivered / abandoned + the last error.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
`CARWASH_REVIEW_URL`, `CARWASH_REVIEW_TOKEN`, `CARWASH_REVIEW_BOOTH_ID` — all three or the outbox
|
||||||
|
is off and **nothing is queued** (an unbounded queue nobody drains is worse than none). Set per
|
||||||
|
booth in the Komodo stack env; compose forwards them.
|
||||||
|
|
||||||
|
## Not built yet: the collector
|
||||||
|
|
||||||
|
A deliberately small service on the overlay: one ingest endpoint (token per booth, size cap),
|
||||||
|
one review screen (crop, the operator's pick, the camera's pick → the reviewer picks the truth),
|
||||||
|
one export (crops + reviewer labels, nothing else) for phase B training. Keep it that small —
|
||||||
|
it must not grow into a fleet console. The Netbird policy: booths may reach the collector's
|
||||||
|
ingest port and nothing else on it.
|
||||||
@@ -196,6 +196,10 @@ onto the site's own categories ("car, sedan, hatchback → Vetura").
|
|||||||
dataset builds itself on park-2. Until then a Vetura/SUV list sees every car as Vetura and no
|
dataset builds itself on park-2. Until then a Vetura/SUV list sees every car as Vetura and no
|
||||||
downgrade fires; van/truck/bus/motorcycle do separate. Reports (discrepancies per operator per
|
downgrade fires; van/truck/bus/motorcycle do separate. Reports (discrepancies per operator per
|
||||||
shift) wait for the first real reads.
|
shift) wait for the first real reads.
|
||||||
|
- **The operator's label is a hypothesis (user, 2026-09-06)** — the training label is a trusted
|
||||||
|
remote reviewer's. Booth side built: [[vision-review-outbox]] (crop + blurred plate + choice,
|
||||||
|
queued off the intake path, drained one-way over Netbird, no site identity leaves). The
|
||||||
|
collector is the open half.
|
||||||
|
|
||||||
## Car Wash — the pilot module (settled 2026-09-05)
|
## Car Wash — the pilot module (settled 2026-09-05)
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
|
|||||||
- [[append-only-event-chain]] — append-only + hash chain + signing = unforgeable log (signing is **software today**; hardware signer pending — see below).
|
- [[append-only-event-chain]] — append-only + hash chain + signing = unforgeable log (signing is **software today**; hardware signer pending — see below).
|
||||||
- [[hardware-signer-options]] — where the ledger signing key should live (TPM interim → USB-HSM target; ATECC608 upcoming, not on-site) so a host-owner can't forge the chain.
|
- [[hardware-signer-options]] — where the ledger signing key should live (TPM interim → USB-HSM target; ATECC608 upcoming, not on-site) so a host-owner can't forge the chain.
|
||||||
- [[reconciliation]] — the real anti-fraud control; what remote sync actually is.
|
- [[reconciliation]] — the real anti-fraud control; what remote sync actually is.
|
||||||
|
- [[vision-review-outbox]] — the wash operator's category choice is a hypothesis: the booth queues a plate-blurred vehicle crop + the choice for a trusted remote reviewer over Netbird (one-way, offline-first, no site identity leaves); the verdict = training label + per-operator error/fraud rate.
|
||||||
- [[disk-os-hardening]] — the *why* of host hardening: LUKS FDE + TPM-sealed auto-unlock (PCR 7) + Secure Boot + GRUB edit-lock + unprivileged operator + firmware/dbx lockdown; secondary control (reconciliation is the main event). Commands → [[appliance-provisioning]].
|
- [[disk-os-hardening]] — the *why* of host hardening: LUKS FDE + TPM-sealed auto-unlock (PCR 7) + Secure Boot + GRUB edit-lock + unprivileged operator + firmware/dbx lockdown; secondary control (reconciliation is the main event). Commands → [[appliance-provisioning]].
|
||||||
- [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only; last-success/error status + schedule are now restart-durable (migration 0025, fixed a "shows Never despite valid backups" bug).
|
- [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only; last-success/error status + schedule are now restart-durable (migration 0025, fixed a "shows Never despite valid backups" bug).
|
||||||
|
|
||||||
|
|||||||
+10
@@ -3100,3 +3100,13 @@ off), input size, detector floor; Dockerfile bakes yolox_s.onnx (best-effort cur
|
|||||||
path; compose forwards it (empty = off). Measured on four real dev entry frames: car at 0.83–0.88,
|
path; compose forwards it (empty = off). Measured on four real dev entry frames: car at 0.83–0.88,
|
||||||
~240–330 ms, empty lane → none. Tests: tests/test_vehicle.py (pure post-processing + composition +
|
~240–330 ms, empty lane → none. Tests: tests/test_vehicle.py (pure post-processing + composition +
|
||||||
missing-model health). Updated [[opencv-anpr-service]], [[venue-modules]].
|
missing-model health). Updated [[opencv-anpr-service]], [[venue-modules]].
|
||||||
|
|
||||||
|
## [2026-09-06] ingest | Car Wash review outbox — booth side
|
||||||
|
User: the operator cannot be fully trusted, so their category choice + the snapshot go to a
|
||||||
|
trusted remote reviewer over Netbird, with the plate blurred and no site identity. Built the booth
|
||||||
|
side: vision reads keep the vehicle and plate boxes as frame fractions (service bbox → client →
|
||||||
|
device_events); `carwash_review_outbox` (0031); `review-outbox.ts` (crop with margin ≤ 640 px,
|
||||||
|
plate blurred in place, pseudonymous booth id + keyed operator hash, multipart POST with a
|
||||||
|
per-booth bearer, backoff, permanent rejections, void/expiry abandon, image dropped once sent);
|
||||||
|
enqueue off the intake path in `createOrder`; `/api/carwash/review/status` + a Setup line; env +
|
||||||
|
compose. New concept page [[vision-review-outbox]]; [[venue-modules]] As built; index.
|
||||||
|
|||||||
Reference in New Issue
Block a user