feat(carwash): entry-stream sampling for the review outbox; park-2 wired to the collector
Build & push images / images (push) Successful in 4m22s
Build & push images / images (push) Successful in 4m22s
The wash stream is small; the entry camera photographs every car in exactly the view the classifier is trained on. The booth can now queue entry vehicle reads as pure training material — crop + the camera's class, no order, no operator, no category. - Core announces every vehicle read (deviceEvents.emitVehicleRead from snapshot.ts); the Car Wash module listens, samples entry reads in-process (sampleEntry: exactly one in N) and queues them (enqueueEntry). CARWASH_REVIEW_ENTRY_SAMPLE=N; 1 = every entry (storage and bandwidth are not the limit — user); 0/unset = off. Forwarded by compose. - Packages carry kind: "wash" | "entry". Collector: kind column, entry meta validated without the operator fields, review screen shows an entry sample as such, export has a kind column, operator agreement computed from wash items only. Setup line shows "1 in N entries sampled"; status carries entrySample. - komodo: park-2's four review lines enabled (collector URL by Netbird DNS name, booth-2, the shared per-booth secret, every entry sampled) — the collector is up on the overlay. - Tests on both sides. Wiki: vision-review-outbox (entry stream + the internet-feed assessment), log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -34,6 +34,10 @@ export interface ReviewUploadConfig {
|
||||
/** Pseudonymous booth id — a label the reviewer maps to a site; never the site name. */
|
||||
readonly boothId: string;
|
||||
readonly intervalSec: number;
|
||||
/** Queue one in N ENTRY vehicle reads (no order attached) for the reviewer — the gate
|
||||
* view is exactly what the classifier is trained on, and the entry stream is many times
|
||||
* the wash stream. 0 = off. */
|
||||
readonly entrySample: number;
|
||||
}
|
||||
|
||||
/** From the server env (Komodo stack env). All three of URL, token and booth id, or off. */
|
||||
@@ -43,7 +47,12 @@ export function reviewUploadConfigFromEnv(env: NodeJS.ProcessEnv = process.env):
|
||||
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 };
|
||||
const sample = Number(env.CARWASH_REVIEW_ENTRY_SAMPLE ?? 0);
|
||||
return {
|
||||
url, token, boothId,
|
||||
intervalSec: Number.isFinite(raw) && raw >= 10 ? raw : 60,
|
||||
entrySample: Number.isInteger(sample) && sample > 0 ? sample : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** The crop's longest edge, in pixels — enough for a reviewer and a classifier, small
|
||||
@@ -145,6 +154,8 @@ export interface OutboxStatus {
|
||||
readonly failed: number;
|
||||
readonly lastSentAt: string | null;
|
||||
readonly lastError: string | null;
|
||||
/** 0 = entry sampling off; N = one in N entry reads is queued. */
|
||||
readonly entrySample: number;
|
||||
}
|
||||
|
||||
export class ReviewOutbox {
|
||||
@@ -154,6 +165,7 @@ export class ReviewOutbox {
|
||||
readonly #fetch: FetchLike;
|
||||
#timer: NodeJS.Timeout | null = null;
|
||||
#draining = false;
|
||||
#entrySeen = 0;
|
||||
|
||||
constructor(db: Db, logger: FastifyBaseLogger, cfg: ReviewUploadConfig | null, fetchFn?: FetchLike) {
|
||||
this.#db = db;
|
||||
@@ -172,35 +184,68 @@ export class ReviewOutbox {
|
||||
* 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;
|
||||
return this.#queue(item.orderId, read, (id, crop) => ({
|
||||
v: 1,
|
||||
kind: "wash",
|
||||
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, classes: [...item.categoryClasses] },
|
||||
service: item.serviceName,
|
||||
vision: { class: read.bodyType, confidence: read.confidence, categoryId: item.visionCategoryId },
|
||||
downgraded: item.downgraded,
|
||||
image: crop,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Every Nth entry read is a sample (N = entrySample); the caller queues it. Counted
|
||||
* in-process, so "1 in 5" is exactly that across a booth's day. */
|
||||
sampleEntry(): boolean {
|
||||
const n = this.#cfg?.entrySample ?? 0;
|
||||
if (n <= 0) return false;
|
||||
this.#entrySeen += 1;
|
||||
return this.#entrySeen % n === 0;
|
||||
}
|
||||
|
||||
/** Queue an ENTRY sample: the crop and the camera's class only — no order, no operator,
|
||||
* no category. Pure training material in the gate view; the reviewer labels it. */
|
||||
async enqueueEntry(read: VehicleRead): Promise<boolean> {
|
||||
if (!this.#cfg) return false;
|
||||
return this.#queue(`entry:${read.snapshotId ?? "?"}`, read, (id, crop) => ({
|
||||
v: 1,
|
||||
kind: "entry",
|
||||
booth: this.#cfg!.boothId,
|
||||
item: id,
|
||||
at: new Date().toISOString(),
|
||||
vision: { class: read.bodyType, confidence: read.confidence },
|
||||
image: crop,
|
||||
}));
|
||||
}
|
||||
|
||||
async #queue(
|
||||
ref: string,
|
||||
read: VehicleRead,
|
||||
build: (id: string, image: { width: number; height: number; plateBlurred: boolean }) => Record<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
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`);
|
||||
this.#logger.info(`carwash review: snapshot ${read.snapshotId} gone (pruned) — ${ref} 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, classes: [...item.categoryClasses] },
|
||||
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 },
|
||||
};
|
||||
const payload = build(id, { 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 })
|
||||
.values({ id, orderId: ref, 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}`);
|
||||
this.#logger.warn(`carwash review: could not queue ${ref}: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -320,6 +365,7 @@ export class ReviewOutbox {
|
||||
return {
|
||||
enabled: this.enabled,
|
||||
boothId: this.#cfg?.boothId ?? null,
|
||||
entrySample: this.#cfg?.entrySample ?? 0,
|
||||
queued: count("queued"),
|
||||
sent: count("sent"),
|
||||
failed: count("failed"),
|
||||
|
||||
Reference in New Issue
Block a user